Contraband

Collapsible Game Boy popup with 7 mini-games (Snake, Breakout, Tetris, 2048, Flappy, Pong, Simon) plus a real Game Boy / GBC emulator (binjgb — bring your own ROM), sound, palettes and LCD FX to kill dead flight time on Torn's travel screen. Cosmetic, offline, no game-state access.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Contraband
// @namespace    torn.contraband.inflight
// @version      1.8.1
// @license MIT
// @description  Collapsible Game Boy popup with 7 mini-games (Snake, Breakout, Tetris, 2048, Flappy, Pong, Simon) plus a real Game Boy / GBC emulator (binjgb — bring your own ROM), sound, palettes and LCD FX to kill dead flight time on Torn's travel screen. Cosmetic, offline, no game-state access.
// @author       BreadHerring
// @match        https://www.torn.com/page.php*
// @match        https://www.torn.com/index.php*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_setClipboard
// @connect      cdn.jsdelivr.net
// @run-at       document-idle
// @noframes
// ==/UserScript==

/*
 * CONTRABAND — an offline, cosmetic toy that mounts a Game Boy on the Torn
 * in-transit (flying) screen with a menu of mini-games (Snake, Breakout,
 * Tetris, 2048, Flappy) to play until you land. Press SELECT to pick a game.
 *
 * It NEVER reads or writes Torn game state, never touches the API, and never
 * automates anything. It is a self-contained overlay sitting on top of the page.
 *
 * A "PLAY GAME / HIDE GAME" toggle is injected into the travel screen's
 * "Traveling" header (titleContainer). Use it to show or hide the handheld;
 * the choice is remembered across page refreshes.
 *
 * ── Tuning detection (if it doesn't auto-appear when flying) ──────────────
 * Detection keys off confirmed in-flight markers: <body data-traveling>,
 * .content-wrapper.travelling, and the #travel-root flight scene. If needed,
 * open the browser console on the travel page and use:
 *     __CONTRABAND__.debug()      // show a live panel of detection signals
 *     __CONTRABAND__.force('on')  // force-mount regardless of detection
 *     __CONTRABAND__.force('off') // force-hide
 *     __CONTRABAND__.force('auto')// back to automatic detection (default)
 *     __CONTRABAND__.signals()    // print the current detection result
 *     __CONTRABAND__.toggle()     // show/hide the handheld (same as the button)
 * Or add one of these to the URL: #cb-debug  #cb-on  #cb-off
 */

(function () {
  'use strict';

  // One instance only.
  if (window.__CONTRABAND__) return;

  // ───────────────────────────────────────────────────────────── constants ──
  var ROOT_ID = 'contraband-root';
  var STYLE_ID = 'contraband-style';

  // Selectable 4-shade palettes (dark → light). PAL is mutated in place by
  // applyPalette so every game/menu (which read PAL.dN at draw time) update live.
  var PALETTES = [
    { name: 'DMG',    d0: '#0f380f', d1: '#306230', d2: '#8bac0f', d3: '#9bbc0f' },
    { name: 'POCKET', d0: '#0b0b0b', d1: '#4d4d4d', d2: '#8f8f8f', d3: '#c7c7c7' },
    { name: 'AMBER',  d0: '#1a0f00', d1: '#5a3410', d2: '#b06b1a', d3: '#f5c96b' }
  ];
  var PAL = { d0: '#0f380f', d1: '#306230', d2: '#8bac0f', d3: '#9bbc0f' };
  function applyPalette(i) {
    var p = PALETTES[((i | 0) % PALETTES.length + PALETTES.length) % PALETTES.length];
    PAL.d0 = p.d0; PAL.d1 = p.d1; PAL.d2 = p.d2; PAL.d3 = p.d3;
  }

  // Runtime cosmetic flags (populated in boot()).
  var FX = { palette: 0, crt: false, reduced: false };

  // ─────────────────────────────────────────────────────────────── storage ──
  // GM_setValue/GM_getValue when available (survives refresh + works in PDA/TM),
  // otherwise localStorage. All access wrapped so a failure never breaks the toy.
  var Storage = (function () {
    var hasGM = typeof GM_setValue === 'function' && typeof GM_getValue === 'function';
    function get(key, def) {
      try {
        if (hasGM) {
          var v = GM_getValue(key, undefined);
          return (v === undefined || v === null) ? def : v;
        }
        var raw = window.localStorage.getItem(key);
        return raw === null ? def : JSON.parse(raw);
      } catch (e) { return def; }
    }
    function set(key, val) {
      try {
        if (hasGM) { GM_setValue(key, val); return; }
        window.localStorage.setItem(key, JSON.stringify(val));
      } catch (e) { /* ignore */ }
    }
    return { get: get, set: set };
  })();

  // ───────────────────────────────────────────────────────────────── sound ──
  // Self-contained WebAudio bleeper. No assets, no network. Off by default;
  // the AudioContext is created/resumed only from a real user gesture (see the
  // unlock() calls in the control handlers), which satisfies autoplay policies.
  var Sound = (function () {
    var ctx = null;
    var enabled = Storage.get('contraband.sound', false) === true;
    function unlock() {
      try {
        if (!ctx) {
          var AC = window.AudioContext || window.webkitAudioContext;
          if (!AC) return;
          ctx = new AC();
        }
        if (ctx.state === 'suspended') ctx.resume();
      } catch (e) {}
    }
    function tone(freq, dur, type, vol, delay) {
      if (!ctx || ctx.state !== 'running') return;
      try {
        var t0 = ctx.currentTime + (delay || 0);
        var osc = ctx.createOscillator(), gain = ctx.createGain();
        osc.type = type || 'square';
        osc.frequency.setValueAtTime(freq, t0);
        gain.gain.setValueAtTime(vol || 0.06, t0);
        gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
        osc.connect(gain); gain.connect(ctx.destination);
        osc.start(t0); osc.stop(t0 + dur + 0.02);
      } catch (e) {}
    }
    function play(name) {
      if (!enabled) return;
      switch (name) {
        case 'move':    tone(200, 0.03, 'square', 0.04); break;
        case 'rotate':  tone(300, 0.03, 'square', 0.05); break;
        case 'eat':     tone(660, 0.06, 'square', 0.07); break;
        case 'flap':    tone(520, 0.05, 'square', 0.06); break;
        case 'score':   tone(784, 0.05, 'square', 0.06); break;
        case 'hit':     tone(320, 0.03, 'square', 0.05); break;
        case 'launch':  tone(880, 0.05, 'square', 0.06); break;
        case 'lock':    tone(240, 0.04, 'square', 0.05); break;
        case 'merge':   tone(440, 0.05, 'square', 0.07); tone(660, 0.06, 'square', 0.06, 0.05); break;
        case 'clear':   tone(523, 0.05, 'square', 0.07); tone(659, 0.05, 'square', 0.07, 0.06); tone(784, 0.08, 'square', 0.07, 0.12); break;
        case 'select':  tone(330, 0.03, 'square', 0.05); break;
        case 'confirm': tone(523, 0.05, 'square', 0.06); tone(784, 0.07, 'square', 0.06, 0.05); break;
        case 'pause':   tone(392, 0.05, 'square', 0.05); break;
        case 'over':    tone(330, 0.08, 'square', 0.07); tone(247, 0.10, 'square', 0.07, 0.09); tone(165, 0.16, 'square', 0.07, 0.20); break;
      }
    }
    return {
      unlock: unlock,
      context: function () { return ctx; },
      play: play,
      isEnabled: function () { return enabled; },
      setEnabled: function (v) { enabled = !!v; Storage.set('contraband.sound', enabled); if (enabled) unlock(); }
    };
  })();

  // ─────────────────────────────────────────────────────────────── haptics ──
  // Mobile vibration for button taps + crashes (no-op on desktop). On by default.
  var Haptics = (function () {
    var enabled = Storage.get('contraband.haptics', true) !== false;
    function can() { return enabled && typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; }
    return {
      tap: function () { if (can()) try { navigator.vibrate(6); } catch (e) {} },
      hit: function () { if (can()) try { navigator.vibrate([0, 25, 40, 35]); } catch (e) {} },
      isEnabled: function () { return enabled; },
      setEnabled: function (v) { enabled = !!v; Storage.set('contraband.haptics', enabled); }
    };
  })();

  // ───────────────────────────────────────────────────────────── input bus ──
  // One abstract bus. Desktop keydown/up and mobile pointer taps both feed it.
  // Game code only ever reads isDown()/justPressed() — never raw DOM events.
  var ACTIONS = ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'];
  function createInput() {
    var down = {}, pressed = {};
    ACTIONS.forEach(function (a) { down[a] = false; pressed[a] = false; });
    return {
      press: function (a) {
        if (down[a] === undefined) return;
        if (!down[a]) pressed[a] = true; // edge-trigger (ignores key auto-repeat)
        down[a] = true;
      },
      release: function (a) {
        if (down[a] === undefined) return;
        down[a] = false;
      },
      isDown: function (a) { return !!down[a]; },
      justPressed: function (a) { return !!pressed[a]; },
      endFrame: function () { for (var k in pressed) pressed[k] = false; },
      resetAll: function () {
        ACTIONS.forEach(function (a) { down[a] = false; pressed[a] = false; });
      }
    };
  }

  // ─────────────────────────────────────────────────────────── cartridges ──
  // Cartridge interface: { id, name, init(api), update(dt), render(g), reset() }
  // api = { W, H, input, storage, cols, rows }
  // Games are platform-agnostic and read input only through api.input.

  function makeSnake() {
    var W = 160, H = 144;          // internal LCD buffer (real GB resolution)
    var CELL = 8;
    var HUD_H = 16;                // top strip for score
    var COLS = W / CELL;           // 20
    var ROWS = (H - HUD_H) / CELL;  // 16
    var OY = HUD_H;                // playfield pixel y-offset

    var HISCORE_KEY = 'contraband.snake.hiscore';

    var api = null;
    var state = 'title';          // title | play | over
    var snake, dir, pendingDir, food, score, hi, moveEvery, acc, wrap, justHigh;

    function placeFood() {
      var free = [];
      for (var y = 0; y < ROWS; y++) {
        for (var x = 0; x < COLS; x++) {
          var on = false;
          for (var i = 0; i < snake.length; i++) {
            if (snake[i].x === x && snake[i].y === y) { on = true; break; }
          }
          if (!on) free.push({ x: x, y: y });
        }
      }
      food = free.length ? free[(Math.random() * free.length) | 0] : null;
    }

    function newGame() {
      var cy = (ROWS / 2) | 0;
      snake = [{ x: 5, y: cy }, { x: 4, y: cy }, { x: 3, y: cy }];
      dir = { x: 1, y: 0 };
      pendingDir = { x: 1, y: 0 };
      score = 0;
      moveEvery = 150;   // ms per step; ramps down as you eat
      acc = 0;
      wrap = api.storage.get('contraband.snake.wrap', false) === true; // Settings toggle
      justHigh = false;
      placeFood();
      state = 'play';
    }

    function readTurn() {
      // Edge-triggered direction changes; reject reversing onto yourself.
      var nd = null;
      if (api.input.justPressed('up')) nd = { x: 0, y: -1 };
      else if (api.input.justPressed('down')) nd = { x: 0, y: 1 };
      else if (api.input.justPressed('left')) nd = { x: -1, y: 0 };
      else if (api.input.justPressed('right')) nd = { x: 1, y: 0 };
      if (nd && !(nd.x === -dir.x && nd.y === -dir.y)) pendingDir = nd;
    }

    function step() {
      dir = pendingDir;
      var head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };

      if (wrap) {                                     // wrap-around mode (Settings)
        head.x = (head.x + COLS) % COLS;
        head.y = (head.y + ROWS) % ROWS;
      } else if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
        return gameOver();                            // walls kill (classic)
      }
      // Self collision (allow moving into the current tail cell, which frees up).
      for (var i = 0; i < snake.length - 1; i++) {
        if (snake[i].x === head.x && snake[i].y === head.y) return gameOver();
      }

      snake.unshift(head);
      if (food && head.x === food.x && head.y === food.y) {
        score++;
        if (moveEvery > 70) moveEvery -= 4;
        api.sound.play('eat');
        placeFood();
      } else {
        snake.pop();
      }
    }

    function gameOver() {
      state = 'over';
      api.sound.play('over'); api.haptic.hit();
      if (score > hi) { hi = score; api.storage.set(HISCORE_KEY, hi); justHigh = true; }
    }

    // ── rendering helpers ──
    function pad(n, len) { n = '' + n; while (n.length < len) n = '0' + n; return n; }
    function text(g, s, x, y, color) {
      g.fillStyle = color;
      g.fillText(s, x, y);
    }

    return {
      id: 'snake',
      name: 'SNAKE',
      init: function (a) {
        api = a;
        hi = parseInt(api.storage.get(HISCORE_KEY, 0), 10) || 0;
        state = 'title';
      },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (state === 'title') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        if (state === 'over') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        // play
        readTurn();
        acc += dt;
        while (acc >= moveEvery) {
          acc -= moveEvery;
          if (state !== 'play') break;
          step();
        }
      },
      render: function (g) {
        // LCD background
        g.fillStyle = PAL.d3;
        g.fillRect(0, 0, W, H);

        g.font = '8px "Courier New", monospace';
        g.textBaseline = 'top';

        // HUD
        text(g, 'SC ' + pad(state === 'title' ? 0 : score, 4), 3, 4, PAL.d0);
        text(g, 'HI ' + pad(hi, 4), W - 62, 4, PAL.d0);
        g.fillStyle = PAL.d1;
        g.fillRect(0, HUD_H - 2, W, 1);

        if (state === 'title') {
          g.textAlign = 'center';
          g.font = 'bold 20px "Courier New", monospace';
          text(g, 'SNAKE', W / 2, 46, PAL.d0);
          g.font = '9px "Courier New", monospace';
          text(g, 'PRESS  A  TO START', W / 2, 84, PAL.d1);
          text(g, 'CONTRABAND', W / 2, 118, PAL.d1);
          g.textAlign = 'left';
          return;
        }

        // food
        if (food) {
          g.fillStyle = PAL.d1;
          g.fillRect(food.x * CELL + 1, OY + food.y * CELL + 1, CELL - 2, CELL - 2);
        }
        // snake
        for (var s = 0; s < snake.length; s++) {
          g.fillStyle = (s === 0) ? PAL.d0 : PAL.d1;
          g.fillRect(snake[s].x * CELL, OY + snake[s].y * CELL, CELL - 1, CELL - 1);
        }

        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.72)';
          g.fillRect(8, 50, W - 16, 52);
          g.textAlign = 'center';
          g.font = 'bold 13px "Courier New", monospace';
          text(g, 'GAME OVER', W / 2, 58, PAL.d3);
          g.font = '9px "Courier New", monospace';
          text(g, 'SCORE ' + score, W / 2, 78, PAL.d3);
          text(g, 'A = RETRY', W / 2, 90, PAL.d2);
          g.textAlign = 'left';
        }
      }
    };
  }

  // Shared tiny render helpers used by the cartridges below.
  function _pad(n, len) { n = '' + n; while (n.length < len) n = '0' + n; return n; }
  function _text(g, s, x, y, color) { g.fillStyle = color; g.fillText(s, x, y); }

  // ── BREAKOUT ──────────────────────────────────────────────────────────────
  function makeBreakout() {
    var W = 160, H = 144, HUD = 14;
    var PADW = 30, PADH = 5, PADY = H - 12;
    var COLS = 10, BW = 16, BH = 7, ROWS = 4, BX = 0, BY = HUD + 6;
    var KEY = 'contraband.breakout.hiscore';
    var api = null, state = 'title', hi = 0;
    var padX, ball, bricks, left, score, lives, speed, justHigh;

    function resetBricks() {
      bricks = []; left = 0;
      for (var r = 0; r < ROWS; r++) {
        bricks[r] = [];
        for (var c = 0; c < COLS; c++) { bricks[r][c] = true; left++; }
      }
    }
    function serve() {
      padX = (W - PADW) / 2;
      ball = { x: W / 2, y: PADY - 4, vx: 0, vy: 0, stuck: true };
    }
    function newGame() { score = 0; lives = 3; speed = 0.10; justHigh = false; resetBricks(); serve(); state = 'play'; }
    function launchBall() {
      ball.stuck = false;
      var ang = Math.random() * 0.5 - 0.25;  // ±~14° from vertical, constant speed
      ball.vx = speed * Math.sin(ang);
      ball.vy = -speed * Math.cos(ang);
      api.sound.play('launch');
    }
    function loseLife() {
      lives--;
      if (lives <= 0) { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
      else { api.sound.play('hit'); serve(); }
    }
    function collide() {
      if (ball.x < 2) { ball.x = 2; ball.vx = Math.abs(ball.vx); }
      if (ball.x > W - 2) { ball.x = W - 2; ball.vx = -Math.abs(ball.vx); }
      if (ball.y < HUD + 1) { ball.y = HUD + 1; ball.vy = Math.abs(ball.vy); }
      if (ball.vy > 0 && ball.y >= PADY - 2 && ball.y <= PADY + PADH &&
          ball.x >= padX - 1 && ball.x <= padX + PADW + 1) {
        ball.y = PADY - 2;
        var hp = (ball.x - (padX + PADW / 2)) / (PADW / 2); // -1..1
        if (hp < -1) hp = -1; else if (hp > 1) hp = 1;
        var ang = hp * 1.05;               // cap to ~60° off vertical (no near-horizontal shots)
        ball.vx = speed * Math.sin(ang);
        ball.vy = -speed * Math.cos(ang);  // always a solid upward component
      }
      if (ball.y >= BY && ball.y < BY + ROWS * BH) {
        var c = Math.floor((ball.x - BX) / BW), r = Math.floor((ball.y - BY) / BH);
        if (r >= 0 && r < ROWS && c >= 0 && c < COLS && bricks[r][c]) {
          bricks[r][c] = false; left--; score += 10; ball.vy = -ball.vy; api.sound.play('hit');
          if (left <= 0) { resetBricks(); speed += 0.015; serve(); }
        }
      }
      if (ball.y > H + 2) loseLife();
    }
    function step(dt) {
      var i = api.input, pv = 0.13 * dt;
      if (i.isDown('left')) padX -= pv;
      if (i.isDown('right')) padX += pv;
      if (padX < 0) padX = 0;
      if (padX > W - PADW) padX = W - PADW;
      if (ball.stuck) {
        ball.x = padX + PADW / 2; ball.y = PADY - 4;
        if (i.justPressed('a') || i.justPressed('start')) launchBall();
        return;
      }
      var dist = speed * dt, steps = Math.max(1, Math.ceil(dist / 2));
      var sx = ball.vx * dt / steps, sy = ball.vy * dt / steps;
      for (var s = 0; s < steps; s++) {
        ball.x += sx; ball.y += sy; collide();
        if (state !== 'play') return;
      }
    }
    return {
      id: 'breakout', name: 'BREAKOUT',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (state === 'title' || state === 'over') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        step(dt);
      },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
        _text(g, 'SC ' + _pad(state === 'title' ? 0 : score, 4), 3, 3, PAL.d0);
        _text(g, 'HI ' + _pad(hi, 4), W - 62, 3, PAL.d0);
        if (state !== 'title') _text(g, 'LIVES ' + lives, W / 2 - 22, 3, PAL.d1);
        g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
        if (state === 'title') {
          g.textAlign = 'center';
          g.font = 'bold 16px "Courier New", monospace'; _text(g, 'BREAKOUT', W / 2, 44, PAL.d0);
          g.font = '9px "Courier New", monospace';
          _text(g, '← → MOVE   A LAUNCH', W / 2, 82, PAL.d1);
          _text(g, 'A = START', W / 2, 108, PAL.d1);
          g.textAlign = 'left'; return;
        }
        for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {
          if (bricks[r][c]) {
            g.fillStyle = (r < 2) ? PAL.d0 : PAL.d1;
            g.fillRect(BX + c * BW + 1, BY + r * BH + 1, BW - 2, BH - 2);
          }
        }
        g.fillStyle = PAL.d0;
        g.fillRect(padX, PADY, PADW, PADH);
        g.fillRect(ball.x - 2, ball.y - 2, 4, 4);
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
          g.textAlign = 'center';
          g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
          g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2);
          g.textAlign = 'left';
        }
      }
    };
  }

  // ── TETRIS ────────────────────────────────────────────────────────────────
  function makeTetris() {
    var W = 160, H = 144, COLS = 10, ROWS = 18, CELL = 7;
    var OX = 6, OY = 6, PX = OX + COLS * CELL + 8; // well origin + side panel x
    var KEY = 'contraband.tetris.hiscore';
    var SHAPES = [
      [[1, 1, 1, 1]],            // I
      [[1, 1], [1, 1]],          // O
      [[0, 1, 0], [1, 1, 1]],    // T
      [[0, 1, 1], [1, 1, 0]],    // S
      [[1, 1, 0], [0, 1, 1]],    // Z
      [[1, 0, 0], [1, 1, 1]],    // J
      [[0, 0, 1], [1, 1, 1]]     // L
    ];
    var api = null, state = 'title', hi = 0, FLASH = 260;
    var board, piece, nextIdx, score, lines, level, acc, dropEvery, dasL, dasR, softAcc, justHigh;
    var flashRows = [], flashT = 0;   // rows blinking before they clear

    function rotate(m) {
      var R = m.length, C = m[0].length, out = [];
      for (var c = 0; c < C; c++) { out[c] = []; for (var r = R - 1; r >= 0; r--) out[c].push(m[r][c]); }
      return out;
    }
    function collides(m, px, py) {
      for (var r = 0; r < m.length; r++) for (var c = 0; c < m[r].length; c++) {
        if (!m[r][c]) continue;
        var x = px + c, y = py + r;
        if (x < 0 || x >= COLS || y >= ROWS) return true;
        if (y >= 0 && board[y][x]) return true;
      }
      return false;
    }
    function spawn() {
      var m = SHAPES[nextIdx].map(function (row) { return row.slice(); });
      nextIdx = (Math.random() * SHAPES.length) | 0;
      piece = { m: m, x: ((COLS - m[0].length) / 2) | 0, y: 0 };
      if (collides(piece.m, piece.x, piece.y)) {
        state = 'over'; api.sound.play('over'); api.haptic.hit();
        if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; }
      }
    }
    function newGame() {
      board = [];
      for (var r = 0; r < ROWS; r++) { board[r] = []; for (var c = 0; c < COLS; c++) board[r][c] = 0; }
      score = 0; lines = 0; level = 0; acc = 0; dropEvery = 600; dasL = 0; dasR = 0; softAcc = 0; flashRows = []; justHigh = false;
      nextIdx = (Math.random() * SHAPES.length) | 0;
      spawn(); state = 'play';
    }
    function fullRows() {
      var rows = [];
      for (var y = 0; y < ROWS; y++) {
        var full = true;
        for (var x = 0; x < COLS; x++) if (!board[y][x]) { full = false; break; }
        if (full) rows.push(y);
      }
      return rows;
    }
    function lock() {
      for (var r = 0; r < piece.m.length; r++) for (var c = 0; c < piece.m[r].length; c++) {
        if (piece.m[r][c]) { var y = piece.y + r; if (y >= 0) board[y][piece.x + c] = 1; }
      }
      var fr = fullRows();
      if (fr.length) { flashRows = fr; flashT = 0; api.sound.play('clear'); } // defer clear for the flash
      else { api.sound.play('lock'); spawn(); }
    }
    function performClear() {
      var cleared = flashRows.length, keep = [], isFlash = {}, i, k;
      for (i = 0; i < flashRows.length; i++) isFlash[flashRows[i]] = true;
      for (var y = 0; y < ROWS; y++) if (!isFlash[y]) keep.push(board[y]);
      while (keep.length < ROWS) { var nr = []; for (k = 0; k < COLS; k++) nr.push(0); keep.unshift(nr); }
      board = keep;
      var tbl = [0, 40, 100, 300, 1200];
      score += tbl[cleared] * (level + 1);
      lines += cleared; level = (lines / 10) | 0;
      dropEvery = Math.max(90, 600 - level * 45);
      flashRows = [];
      spawn();
    }
    function softStep() { if (!collides(piece.m, piece.x, piece.y + 1)) piece.y++; else lock(); }
    function tryMove(dx) { if (!collides(piece.m, piece.x + dx, piece.y)) piece.x += dx; }
    function tryRotate() {
      var r = rotate(piece.m), kicks = [0, -1, 1, -2, 2];
      for (var k = 0; k < kicks.length; k++) {
        if (!collides(r, piece.x + kicks[k], piece.y)) { piece.m = r; piece.x += kicks[k]; api.sound.play('rotate'); return; }
      }
    }
    function hardDrop() { while (!collides(piece.m, piece.x, piece.y + 1)) piece.y++; lock(); }
    function step(dt) {
      var i = api.input;
      if (i.justPressed('left')) { tryMove(-1); dasL = 170; }
      else if (i.isDown('left')) { dasL -= dt; if (dasL <= 0) { tryMove(-1); dasL = 60; } }
      if (i.justPressed('right')) { tryMove(1); dasR = 170; }
      else if (i.isDown('right')) { dasR -= dt; if (dasR <= 0) { tryMove(1); dasR = 60; } }
      if (i.justPressed('a') || i.justPressed('up')) tryRotate();
      if (i.justPressed('b')) hardDrop();
      // Gravity: steady, and capped so a big dt (tab throttle) can't dump rows.
      acc += dt;
      var gsteps = 0;
      while (acc >= dropEvery && gsteps < 2) { acc -= dropEvery; gsteps++; softStep(); if (state !== 'play') return; }
      if (acc > dropEvery) acc = dropEvery;
      // Soft drop: one cell every 45ms while Down is held — steady, never a leap.
      if (i.isDown('down')) {
        softAcc += dt;
        if (softAcc >= 45) { softAcc = 0; softStep(); score += 1; if (state !== 'play') return; }
      } else softAcc = 0;
    }
    function blk(g, x, y, color) { g.fillStyle = color; g.fillRect(x + 1, y + 1, CELL - 1, CELL - 1); }
    return {
      id: 'tetris', name: 'TETRIS',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (state === 'title' || state === 'over') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        if (flashRows.length) { flashT += dt; if (flashT >= FLASH) performClear(); return; } // blink then clear
        step(dt);
      },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.fillStyle = PAL.d1; g.fillRect(OX - 2, OY - 2, COLS * CELL + 4, ROWS * CELL + 4);
        g.fillStyle = PAL.d3; g.fillRect(OX, OY, COLS * CELL, ROWS * CELL);
        g.textBaseline = 'top';
        if (state === 'title') {
          g.textAlign = 'center';
          g.font = 'bold 15px "Courier New", monospace'; _text(g, 'TETRIS', W / 2, 44, PAL.d0);
          g.font = '8px "Courier New", monospace';
          _text(g, 'A/↑ ROTATE  B DROP', W / 2, 82, PAL.d1);
          _text(g, 'A = START', W / 2, 104, PAL.d1);
          g.textAlign = 'left'; return;
        }
        var blink = flashRows.length && (Math.floor(flashT / 40) % 2) === 0;
        for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {
          if (board[r][c]) {
            var col = PAL.d1;
            if (flashRows.length && flashRows.indexOf(r) >= 0) col = blink ? PAL.d3 : PAL.d0;
            blk(g, OX + c * CELL, OY + r * CELL, col);
          }
        }
        if (state === 'play' && !flashRows.length) {
          for (var pr = 0; pr < piece.m.length; pr++) for (var pc = 0; pc < piece.m[pr].length; pc++) {
            if (piece.m[pr][pc]) { var yy = piece.y + pr; if (yy >= 0) blk(g, OX + (piece.x + pc) * CELL, OY + yy * CELL, PAL.d0); }
          }
        }
        g.font = '8px "Courier New", monospace';
        _text(g, 'SCORE', PX, 8, PAL.d0); _text(g, '' + score, PX, 18, PAL.d1);
        _text(g, 'LINES', PX, 36, PAL.d0); _text(g, '' + lines, PX, 46, PAL.d1);
        _text(g, 'LEVEL', PX, 64, PAL.d0); _text(g, '' + level, PX, 74, PAL.d1);
        _text(g, 'HI', PX, 92, PAL.d0); _text(g, '' + hi, PX, 102, PAL.d1);
        _text(g, 'NEXT', PX, 118, PAL.d0);
        var nm = SHAPES[nextIdx];
        for (var a = 0; a < nm.length; a++) for (var b = 0; b < nm[a].length; b++) {
          if (nm[a][b]) blk(g, PX + b * 6 + 1, 128 + a * 6, PAL.d0);
        }
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.8)'; g.fillRect(OX - 2, 54, COLS * CELL + 4, 42);
          g.textAlign = 'center';
          g.font = 'bold 11px "Courier New", monospace';
          _text(g, 'GAME', OX + COLS * CELL / 2, 58, PAL.d3);
          _text(g, 'OVER', OX + COLS * CELL / 2, 70, PAL.d3);
          g.font = '8px "Courier New", monospace'; _text(g, 'A=RETRY', OX + COLS * CELL / 2, 84, PAL.d2);
          g.textAlign = 'left';
        }
      }
    };
  }

  // ── 2048 ──────────────────────────────────────────────────────────────────
  function make2048() {
    var W = 160, H = 144, HUD = 16, N = 4, PAD = 2;
    var AREA = 128, OX = (W - AREA) / 2, OY = HUD, CELL = AREA / N; // 32
    var ANIM = 90, POP = 90;   // ms: tile slide + spawn/merge pop durations
    var KEY = 'contraband.2048.hiscore';
    var api = null, state = 'title', hi = 0, grid, score, won, justHigh, prevHi = 0;
    var animating = false, anim = null, animT = 0, pendingGrid = null, pendingGained = 0;
    var popCells = [], mergeCells = [], popT = 0;   // new-tile grow-in + merged-tile pop

    function emptyGrid() { var a = []; for (var i = 0; i < N * N; i++) a.push(0); return a; }
    function ix(r, c) { return r * N + c; }
    function place() {
      var free = [];
      for (var i = 0; i < grid.length; i++) if (!grid[i]) free.push(i);
      if (free.length) grid[free[(Math.random() * free.length) | 0]] = Math.random() < 0.9 ? 2 : 4;
    }
    function newGame() {
      grid = emptyGrid(); score = 0; won = false; prevHi = hi; justHigh = false;
      animating = false; anim = null; popCells = []; mergeCells = []; popT = POP;
      place(); place(); state = 'play';
    }
    function movesLeft() {
      for (var i = 0; i < grid.length; i++) if (!grid[i]) return true;
      for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
        var v = grid[ix(r, c)];
        if (c + 1 < N && grid[ix(r, c + 1)] === v) return true;
        if (r + 1 < N && grid[ix(r + 1, c)] === v) return true;
      }
      return false;
    }
    // Cell order for one line; index 0 is the destination end tiles pile toward.
    function lineCoords(dir, k) {
      var a = [];
      for (var t = 0; t < N; t++) {
        if (dir === 3) a.push({ r: k, c: t });              // left
        else if (dir === 1) a.push({ r: k, c: N - 1 - t }); // right
        else if (dir === 0) a.push({ r: t, c: k });         // up
        else a.push({ r: N - 1 - t, c: k });                // down
      }
      return a;
    }
    // Plan a move WITHOUT mutating grid: returns each tile's from→to + merged board.
    function computeMove(dir) {
      var moves = [], gained = 0, newGrid = emptyGrid();
      for (var k = 0; k < N; k++) {
        var cells = lineCoords(dir, k), seq = [];
        for (var t = 0; t < cells.length; t++) {
          var v = grid[ix(cells[t].r, cells[t].c)];
          if (v) seq.push({ r: cells[t].r, c: cells[t].c, val: v });
        }
        var wi = 0;
        for (var j = 0; j < seq.length; j++) {
          var d = cells[wi];
          if (j + 1 < seq.length && seq[j].val === seq[j + 1].val) {
            var mv = seq[j].val * 2; gained += mv; if (mv === 2048) won = true;
            newGrid[ix(d.r, d.c)] = mv;
            moves.push({ fr: seq[j].r, fc: seq[j].c, tr: d.r, tc: d.c, val: seq[j].val, merge: false });
            moves.push({ fr: seq[j + 1].r, fc: seq[j + 1].c, tr: d.r, tc: d.c, val: seq[j + 1].val, merge: true });
            wi++; j++;
          } else {
            newGrid[ix(d.r, d.c)] = seq[j].val;
            moves.push({ fr: seq[j].r, fc: seq[j].c, tr: d.r, tc: d.c, val: seq[j].val, merge: false });
            wi++;
          }
        }
      }
      var moved = false;
      for (var m = 0; m < moves.length; m++) {
        var o = moves[m];
        if (o.fr !== o.tr || o.fc !== o.tc || o.merge) { moved = true; break; }
      }
      return { moves: moves, gained: gained, moved: moved, newGrid: newGrid };
    }
    function move(dir) {
      if (animating) return;
      var res = computeMove(dir);
      if (!res.moved) return;
      anim = res.moves; pendingGrid = res.newGrid; pendingGained = res.gained;
      animT = 0; animating = true;
    }
    function finishMove() {
      grid = pendingGrid; score += pendingGained;
      if (score > hi) { hi = score; api.storage.set(KEY, hi); }
      mergeCells = []; popCells = [];
      for (var m = 0; m < anim.length; m++) if (anim[m].merge) mergeCells.push(ix(anim[m].tr, anim[m].tc));
      var free = [];
      for (var i = 0; i < grid.length; i++) if (!grid[i]) free.push(i);
      if (free.length) { var s = free[(Math.random() * free.length) | 0]; grid[s] = Math.random() < 0.9 ? 2 : 4; popCells.push(s); }
      api.sound.play(mergeCells.length ? 'merge' : 'move');
      animating = false; anim = null; popT = 0;
      if (!movesLeft()) { state = 'over'; api.sound.play('over'); api.haptic.hit(); justHigh = score > prevHi; }
    }
    function ln2(v) { return Math.round(Math.log(v) / Math.LN2); }
    function shade(v) { var e = ln2(v); return e <= 2 ? PAL.d2 : (e <= 5 ? PAL.d1 : PAL.d0); }
    function txt(v) { return ln2(v) <= 2 ? PAL.d0 : PAL.d3; }
    function drawTile(g, x, y, v, scale) {
      var s = CELL - PAD * 2, w = s * scale, cx = x + CELL / 2, cy = y + CELL / 2;
      g.fillStyle = shade(v); g.fillRect(cx - w / 2, cy - w / 2, w, w);
      if (scale >= 0.7) {
        g.font = 'bold ' + (v < 100 ? 15 : v < 1000 ? 12 : 9) + 'px "Courier New", monospace';
        g.textAlign = 'center'; g.textBaseline = 'middle';
        _text(g, '' + v, cx, cy, txt(v));
        g.textAlign = 'left'; g.textBaseline = 'top';
      }
    }
    return {
      id: '2048', name: '2048',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (state === 'title' || state === 'over') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        if (animating) { animT += dt; if (animT >= ANIM) finishMove(); return; }
        if (popT < POP) popT += dt;
        if (i.justPressed('up')) move(0);
        else if (i.justPressed('right')) move(1);
        else if (i.justPressed('down')) move(2);
        else if (i.justPressed('left')) move(3);
      },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
        _text(g, 'SCORE ' + (state === 'title' ? 0 : score), 3, 4, PAL.d0);
        _text(g, 'HI ' + hi, W - 56, 4, PAL.d0);
        if (state === 'title') {
          g.textAlign = 'center';
          g.font = 'bold 22px "Courier New", monospace'; _text(g, '2048', W / 2, 42, PAL.d0);
          g.font = '9px "Courier New", monospace';
          _text(g, 'D-PAD TO SLIDE', W / 2, 84, PAL.d1);
          _text(g, 'A = START', W / 2, 108, PAL.d1);
          g.textAlign = 'left'; return;
        }
        // faint empty slots so the board frame stays readable
        for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
          var ex = OX + c * CELL, ey = OY + r * CELL;
          g.fillStyle = PAL.d2; g.globalAlpha = 0.22;
          g.fillRect(ex + PAD, ey + PAD, CELL - PAD * 2, CELL - PAD * 2); g.globalAlpha = 1;
        }
        if (animating) {
          var p = Math.min(1, animT / ANIM), e = p * (2 - p); // easeOutQuad slide
          for (var m = 0; m < anim.length; m++) {
            var mo = anim[m];
            var fx = OX + mo.fc * CELL, fy = OY + mo.fr * CELL;
            var tx = OX + mo.tc * CELL, ty = OY + mo.tr * CELL;
            drawTile(g, fx + (tx - fx) * e, fy + (ty - fy) * e, mo.val, 1);
          }
        } else {
          var pop = popT < POP ? popT / POP : 1, ep = pop * (2 - pop);
          var overshoot = 1 + 0.22 * Math.sin(Math.PI * pop); // merged tile "punch"
          for (var r2 = 0; r2 < N; r2++) for (var c2 = 0; c2 < N; c2++) {
            var v = grid[ix(r2, c2)]; if (!v) continue;
            var id = ix(r2, c2), sc = 1;
            if (pop < 1) {
              if (mergeCells.indexOf(id) >= 0) sc = overshoot;             // merge: pop out then settle
              else if (popCells.indexOf(id) >= 0) sc = 0.35 + 0.65 * ep;   // new tile: grow in
            }
            drawTile(g, OX + c2 * CELL, OY + r2 * CELL, v, sc);
          }
        }
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.78)'; g.fillRect(8, 56, W - 16, 40);
          g.textAlign = 'center';
          g.font = 'bold 12px "Courier New", monospace'; _text(g, 'NO MOVES', W / 2, 62, PAL.d3);
          g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 80, PAL.d2);
          g.textAlign = 'left';
        }
      }
    };
  }

  // ── FLAPPY (one button) ─────────────────────────────────────────────────────
  function makeFlappy() {
    var W = 160, H = 144, GND = H - 12, GAP = 54, PIPEW = 16, BIRDX = 44;
    // Gentler than classic: soft gravity, a small controllable flap, and a
    // terminal fall speed so it never plummets. SPAWN = ms between pipes.
    var GRAV = 0.00095, FLAP = -0.215, TERM = 0.30, SPD = 0.052, SPAWN = 1650;
    var KEY = 'contraband.flappy.hiscore';
    var api = null, state = 'title', hi = 0, bird, pipes, spawnT, score, justHigh;

    // Start with a gentle upward drift + a grace delay before the first pipe.
    function newGame() { bird = { y: H / 2, vy: FLAP * 0.5 }; pipes = []; spawnT = 1000; score = 0; justHigh = false; state = 'play'; }
    function addPipe() {
      var margin = 16, gap = margin + Math.random() * (GND - GAP - margin * 2);
      pipes.push({ x: W + 4, gap: gap, passed: false });
    }
    function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
    function step(dt) {
      var i = api.input;
      if (i.justPressed('a') || i.justPressed('start') || i.justPressed('up')) { bird.vy = FLAP; api.sound.play('flap'); }
      bird.vy += GRAV * dt;
      if (bird.vy > TERM) bird.vy = TERM;   // cap fall speed so it never plummets
      bird.y += bird.vy * dt;
      if (bird.y < 3) { bird.y = 3; bird.vy = 0; }
      if (bird.y > GND - 4) { bird.y = GND - 4; return over(); }
      spawnT -= dt;
      if (spawnT <= 0) { addPipe(); spawnT = SPAWN; }
      for (var p = pipes.length - 1; p >= 0; p--) {
        var pi = pipes[p];
        pi.x -= SPD * dt;
        if (!pi.passed && pi.x + PIPEW < BIRDX) { pi.passed = true; score++; api.sound.play('score'); }
        if (BIRDX + 4 > pi.x && BIRDX - 4 < pi.x + PIPEW &&
            (bird.y - 4 < pi.gap || bird.y + 4 > pi.gap + GAP)) return over();
        if (pi.x + PIPEW < -4) pipes.splice(p, 1);
      }
    }
    return {
      id: 'flappy', name: 'FLAPPY',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (state === 'title' || state === 'over') {
          if (i.justPressed('a') || i.justPressed('start')) newGame();
          return;
        }
        step(dt);
      },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.fillStyle = PAL.d1; g.fillRect(0, GND, W, H - GND);
        g.textBaseline = 'top';
        if (state === 'title') {
          g.textAlign = 'center';
          g.font = 'bold 17px "Courier New", monospace'; _text(g, 'FLAPPY', W / 2, 40, PAL.d0);
          g.font = '9px "Courier New", monospace';
          _text(g, 'A = FLAP', W / 2, 78, PAL.d1);
          _text(g, 'A = START', W / 2, 104, PAL.d1);
          g.textAlign = 'left'; return;
        }
        g.fillStyle = PAL.d0;
        for (var p = 0; p < pipes.length; p++) {
          var pi = pipes[p];
          g.fillRect(pi.x, 0, PIPEW, pi.gap);
          g.fillRect(pi.x, pi.gap + GAP, PIPEW, GND - (pi.gap + GAP));
        }
        g.fillStyle = PAL.d0; g.fillRect(BIRDX - 4, bird.y - 4, 8, 8);
        g.fillStyle = PAL.d3; g.fillRect(BIRDX, bird.y - 2, 2, 2); // eye
        g.textAlign = 'center';
        g.font = 'bold 14px "Courier New", monospace'; _text(g, '' + score, W / 2, 6, PAL.d0);
        g.textAlign = 'left';
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.78)'; g.fillRect(8, 54, W - 16, 44);
          g.textAlign = 'center';
          g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
          g.font = '9px "Courier New", monospace'; _text(g, 'SCORE ' + score + '  A=RETRY', W / 2, 82, PAL.d2);
          g.textAlign = 'left';
        }
      }
    };
  }

  // ── PONG (vs CPU) ───────────────────────────────────────────────────────────
  function makePong() {
    var W = 160, H = 144, HUD = 12, PW = 3, PH = 24, BALL = 4;
    var PXL = 6, PXR = W - 6 - PW;
    var KEY = 'contraband.pong.hiscore';
    var api = null, state = 'title', hi = 0, justHigh = false;
    var py, ay, ball, spd, score;
    function serve(dir) { ball = { x: W / 2, y: H / 2, vx: spd * dir, vy: spd * (Math.random() * 0.6 - 0.3) }; }
    function newGame() { py = (H - PH) / 2; ay = (H - PH) / 2; spd = 0.10; score = 0; justHigh = false; serve(Math.random() < 0.5 ? -1 : 1); state = 'play'; }
    function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
    function step(dt) {
      var i = api.input, pv = 0.16 * dt;
      if (i.isDown('up')) py -= pv;
      if (i.isDown('down')) py += pv;
      if (py < HUD) py = HUD; if (py > H - PH) py = H - PH;
      var cv = 0.108 * dt;                        // CPU slightly slower than max ball speed → it can miss
      if (ay + PH / 2 < ball.y - 2) ay += cv; else if (ay + PH / 2 > ball.y + 2) ay -= cv;
      if (ay < HUD) ay = HUD; if (ay > H - PH) ay = H - PH;
      var dist = Math.max(Math.abs(ball.vx), Math.abs(ball.vy)) * dt, steps = Math.max(1, Math.ceil(dist / 2));
      var sx = ball.vx * dt / steps, sy = ball.vy * dt / steps;
      for (var s = 0; s < steps; s++) {
        ball.x += sx; ball.y += sy;
        if (ball.y < HUD) { ball.y = HUD; ball.vy = Math.abs(ball.vy); }
        if (ball.y > H - BALL) { ball.y = H - BALL; ball.vy = -Math.abs(ball.vy); }
        if (ball.vx < 0 && ball.x <= PXL + PW && ball.x >= PXL - 3 && ball.y + BALL >= py && ball.y <= py + PH) {
          ball.x = PXL + PW; spd = Math.min(0.20, spd + 0.004); ball.vx = spd;
          ball.vy = spd * ((ball.y - (py + PH / 2)) / (PH / 2)); api.sound.play('hit');
        }
        if (ball.vx > 0 && ball.x + BALL >= PXR && ball.x <= PXR + PW && ball.y + BALL >= ay && ball.y <= ay + PH) {
          ball.x = PXR - BALL; ball.vx = -spd;
          ball.vy = spd * ((ball.y - (ay + PH / 2)) / (PH / 2)); api.sound.play('hit');
        }
        if (ball.x < -2) return over();
        if (ball.x > W + 2) { score++; api.sound.play('score'); serve(-1); return; }
      }
    }
    return {
      id: 'pong', name: 'PONG',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state === 'play'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) { var i = api.input; if (state === 'title' || state === 'over') { if (i.justPressed('a') || i.justPressed('start')) newGame(); return; } step(dt); },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
        _text(g, 'SC ' + _pad(state === 'title' ? 0 : score, 3), 3, 3, PAL.d0);
        _text(g, 'HI ' + _pad(hi, 3), W - 50, 3, PAL.d0);
        g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
        for (var yy = HUD + 2; yy < H; yy += 8) { g.fillStyle = PAL.d2; g.fillRect(W / 2 - 1, yy, 2, 4); }
        if (state === 'title') {
          g.textAlign = 'center'; g.font = 'bold 20px "Courier New", monospace'; _text(g, 'PONG', W / 2, 44, PAL.d0);
          g.font = '9px "Courier New", monospace'; _text(g, 'UP / DOWN', W / 2, 82, PAL.d1); _text(g, 'A = START', W / 2, 104, PAL.d1);
          g.textAlign = 'left'; return;
        }
        g.fillStyle = PAL.d0;
        g.fillRect(PXL, py, PW, PH); g.fillRect(PXR, ay, PW, PH); g.fillRect(ball.x, ball.y, BALL, BALL);
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
          g.textAlign = 'center'; g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
          g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2); g.textAlign = 'left';
        }
      }
    };
  }

  // ── SIMON (memory) ──────────────────────────────────────────────────────────
  function makeSimon() {
    var W = 160, H = 144, HUD = 12;
    var KEY = 'contraband.simon.hiscore';
    var api = null, state = 'title', hi = 0, justHigh = false;
    var seq = [], showIdx = 0, showOn = false, timer = 0, inIdx = 0, score = 0, activePad = -1, flashT = 0;
    var PADS = [
      { x: W / 2 - 24, y: HUD + 3, w: 48, h: 30, dir: 'up' },
      { x: W - 46, y: HUD + 38, w: 40, h: 30, dir: 'right' },
      { x: W / 2 - 24, y: H - 33, w: 48, h: 30, dir: 'down' },
      { x: 6, y: HUD + 38, w: 40, h: 30, dir: 'left' }
    ];
    var SND = ['flap', 'score', 'hit', 'launch'];   // 4 distinct pitches
    function padOf(name) { for (var i = 0; i < PADS.length; i++) if (PADS[i].dir === name) return i; return -1; }
    function flash(p) { activePad = p; flashT = 260; if (p >= 0) api.sound.play(SND[p]); }
    function nextRound() { seq.push((Math.random() * 4) | 0); state = 'show'; showIdx = 0; showOn = false; timer = 450; }
    function newGame() { seq = []; score = 0; justHigh = false; nextRound(); }
    function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
    return {
      id: 'simon', name: 'SIMON',
      init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
      reset: function () { state = 'title'; },
      isPlaying: function () { return state !== 'title' && state !== 'over'; },
      tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
      update: function (dt) {
        var i = api.input;
        if (flashT > 0) { flashT -= dt; if (flashT <= 0) activePad = -1; }
        if (state === 'title' || state === 'over') { if (i.justPressed('a') || i.justPressed('start')) newGame(); return; }
        if (state === 'show') {
          timer -= dt;
          if (timer <= 0) {
            if (!showOn) { flash(seq[showIdx]); showOn = true; timer = 300; }
            else { activePad = -1; showOn = false; showIdx++; timer = 170; if (showIdx >= seq.length) { state = 'input'; inIdx = 0; } }
          }
          return;
        }
        if (state === 'wait') { timer -= dt; if (timer <= 0) nextRound(); return; }
        var p = -1;
        if (i.justPressed('up')) p = padOf('up');
        else if (i.justPressed('right')) p = padOf('right');
        else if (i.justPressed('down')) p = padOf('down');
        else if (i.justPressed('left')) p = padOf('left');
        if (p >= 0) {
          flash(p);
          if (p === seq[inIdx]) {
            inIdx++;
            if (inIdx >= seq.length) { score = seq.length; api.sound.play('confirm'); state = 'wait'; timer = 520; }
          } else over();
        }
      },
      render: function (g) {
        g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
        g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
        _text(g, 'RND ' + _pad(state === 'title' ? 0 : seq.length, 2), 3, 3, PAL.d0);
        _text(g, 'HI ' + _pad(hi, 2), W - 42, 3, PAL.d0);
        g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
        if (state === 'title') {
          g.textAlign = 'center'; g.font = 'bold 18px "Courier New", monospace'; _text(g, 'SIMON', W / 2, 44, PAL.d0);
          g.font = '9px "Courier New", monospace'; _text(g, 'REPEAT THE PATTERN', W / 2, 80, PAL.d1); _text(g, 'A = START', W / 2, 104, PAL.d1);
          g.textAlign = 'left'; return;
        }
        for (var k = 0; k < PADS.length; k++) {
          var pd = PADS[k];
          g.fillStyle = (k === activePad) ? PAL.d3 : (k % 2 ? PAL.d1 : PAL.d0);
          g.fillRect(pd.x, pd.y, pd.w, pd.h);
          g.fillStyle = (k === activePad) ? PAL.d0 : PAL.d2;
          g.fillRect(pd.x + 2, pd.y + 2, pd.w - 4, pd.h - 4);
        }
        g.textAlign = 'center'; g.textBaseline = 'middle'; g.font = '8px "Courier New", monospace';
        var m = state === 'show' ? 'WATCH' : (state === 'input' ? 'YOUR TURN' : (state === 'wait' ? 'GOOD!' : ''));
        _text(g, m, W / 2, H / 2 + 2, PAL.d0);
        g.textAlign = 'left'; g.textBaseline = 'top';
        if (state === 'over') {
          g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
          g.textAlign = 'center'; g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
          g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2); g.textAlign = 'left';
        }
      }
    };
  }

  // Cartridge registry — Snake first (default menu selection).
  // ══════════════════════════════════════════════ emulator core (binjgb) ══
  // binjgb by Ben Smith (MIT, https://github.com/binji/binjgb), compiled to WASM
  // with -sDYNAMIC_EXECUTION=0 (CSP-safe) and beautified (non-minified). The wasm
  // itself is fetched at runtime from jsDelivr and SHA-256-verified (see makeEmulator).
var Binjgb = (() => {
  var _scriptName = globalThis.document?.currentScript?.src;
  return async function(moduleArg = {}) {
    var Module = moduleArg;
    var ENVIRONMENT_IS_WEB = true;
    var ENVIRONMENT_IS_WORKER = false;
    var programArgs = [];
    var thisProgram = "./this.program";
    var quit_ = (status, toThrow) => {
      throw toThrow
    };
    var scriptDirectory = "";

    function locateFile(path) {
      if (Module["locateFile"]) {
        return Module["locateFile"](path, scriptDirectory)
      }
      return scriptDirectory + path
    }
    var readAsync, readBinary;
    if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
      try {
        scriptDirectory = new URL(".", _scriptName).href
      } catch {} {
        readAsync = async url => {
          var response = await fetch(url, {
            credentials: "same-origin"
          });
          if (response.ok) {
            return response.arrayBuffer()
          }
          throw new Error(response.status + " : " + response.url)
        }
      }
    } else {}
    var out = console.log.bind(console);
    var err = console.error.bind(console);
    var wasmBinary;
    var ABORT = false;
    var EXITSTATUS;
    class EmscriptenEH {}
    class EmscriptenSjLj extends EmscriptenEH {}
    var runtimeInitialized = false;

    function getMemoryBuffer() {
      try {
        var b = wasmMemory.toResizableBuffer();
        return b
      } catch {}
      return wasmMemory.buffer
    }

    function updateMemoryViews() {
      if (HEAP8?.buffer?.resizable) return;
      var b = getMemoryBuffer();
      Module["HEAP8"] = HEAP8 = new Int8Array(b);
      Module["HEAP16"] = HEAP16 = new Int16Array(b);
      Module["HEAPU8"] = HEAPU8 = new Uint8Array(b);
      Module["HEAPU16"] = HEAPU16 = new Uint16Array(b);
      Module["HEAP32"] = HEAP32 = new Int32Array(b);
      Module["HEAPU32"] = HEAPU32 = new Uint32Array(b);
      Module["HEAPF32"] = HEAPF32 = new Float32Array(b);
      Module["HEAPF64"] = HEAPF64 = new Float64Array(b);
      HEAP64 = new BigInt64Array(b);
      HEAPU64 = new BigUint64Array(b)
    }

    function preRun() {
      var preRun = Module["preRun"];
      if (preRun) {
        if (typeof preRun == "function") preRun = [preRun];
        onPreRuns.push(...preRun)
      }
      callRuntimeCallbacks(onPreRuns)
    }

    function initRuntime() {
      runtimeInitialized = true;
      wasmExports["e"]()
    }

    function postRun() {
      var postRun = Module["postRun"];
      if (postRun) {
        if (typeof postRun == "function") postRun = [postRun];
        onPostRuns.push(...postRun)
      }
      callRuntimeCallbacks(onPostRuns)
    }

    function abort(what) {
      Module["onAbort"]?.(what);
      what = `Aborted(${what})`;
      err(what);
      ABORT = true;
      what += ". Build with -sASSERTIONS for more info.";
      var e = new WebAssembly.RuntimeError(what);
      throw e
    }
    var wasmBinaryFile;

    function findWasmBinary() {
      return locateFile("binjgb.wasm")
    }

    function getBinarySync(file) {
      if (readBinary) {
        return readBinary(file)
      }
      throw "both async and sync fetching of the wasm failed"
    }
    async function getWasmBinary(binaryFile) {
      if (!wasmBinary) {
        try {
          var response = await readAsync(binaryFile);
          return new Uint8Array(response)
        } catch {}
      }
      return getBinarySync(binaryFile)
    }
    async function instantiateArrayBuffer(binaryFile, imports) {
      try {
        var binary = await getWasmBinary(binaryFile);
        var instance = await WebAssembly.instantiate(binary, imports);
        return instance
      } catch (reason) {
        err(`failed to asynchronously prepare wasm: ${reason}`);
        abort(reason)
      }
    }
    async function instantiateAsync(binary, binaryFile, imports) {
      if (!binary) {
        try {
          var response = fetch(binaryFile, {
            credentials: "same-origin"
          });
          var instantiationResult = await WebAssembly.instantiateStreaming(response, imports);
          return instantiationResult
        } catch (reason) {
          err(`wasm streaming compile failed: ${reason}`);
          err("falling back to ArrayBuffer instantiation")
        }
      }
      return instantiateArrayBuffer(binaryFile, imports)
    }

    function getWasmImports() {
      var imports = {
        a: wasmImports
      };
      return imports
    }
    async function createWasm() {
      function receiveInstance(instance) {
        wasmExports = instance.exports;
        assignWasmExports(wasmExports);
        updateMemoryViews();
        return wasmExports
      }

      function receiveInstantiationResult(result) {
        return receiveInstance(result["instance"])
      }
      var info = getWasmImports();
      var instantiateWasm = Module["instantiateWasm"];
      if (instantiateWasm) {
        return new Promise(resolve => {
          instantiateWasm(info, inst => resolve(receiveInstance(inst)))
        })
      }
      wasmBinaryFile ??= findWasmBinary();
      var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info);
      var exports = receiveInstantiationResult(result);
      return exports
    }
    class ExitStatus {
      name = "ExitStatus";
      constructor(status) {
        this.message = `Program terminated with exit(${status})`;
        this.status = status
      }
    }
    var HEAP16;
    var HEAP32;
    var HEAP64;
    var HEAP8;
    var HEAPF32;
    var HEAPF64;
    var HEAPU16;
    var HEAPU32;
    var HEAPU64;
    var HEAPU8;
    var callRuntimeCallbacks = callbacks => {
      while (callbacks.length > 0) {
        callbacks.shift()(Module)
      }
    };
    var onPostRuns = [];
    var onPreRuns = [];
    var noExitRuntime = true;
    var getHeapMax = () => 2147483648;
    var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment;
    var growMemory = size => {
      var oldHeapSize = wasmMemory.buffer.byteLength;
      var pages = (size - oldHeapSize + 65535) / 65536 | 0;
      try {
        wasmMemory.grow(pages);
        updateMemoryViews();
        return 1
      } catch (e) {}
    };
    var _emscripten_resize_heap = requestedSize => {
      var oldSize = HEAPU8.length;
      requestedSize >>>= 0;
      var maxHeapSize = getHeapMax();
      if (requestedSize > maxHeapSize) {
        return false
      }
      for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
        var overGrownHeapSize = oldSize * (1 + .2 / cutDown);
        overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296);
        var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536));
        var replacement = growMemory(newSize);
        if (replacement) {
          return true
        }
      }
      return false
    };
    var runtimeKeepaliveCounter = 0;
    var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0;
    var _proc_exit = code => {
      EXITSTATUS = code;
      if (!keepRuntimeAlive()) {
        Module["onExit"]?.(code);
        ABORT = true
      }
      quit_(code, new ExitStatus(code))
    };
    var exitJS = (status, implicit) => {
      EXITSTATUS = status;
      _proc_exit(status)
    };
    var _exit = exitJS;
    var printCharBuffers = [null, [],
      []
    ];
    var UTF8Decoder = globalThis.TextDecoder && new TextDecoder;
    var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => {
      var maxIdx = idx + maxBytesToRead;
      if (ignoreNul) return maxIdx;
      while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx;
      return idx
    };
    var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => {
      var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul);
      if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) {
        return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr))
      }
      var str = "";
      while (idx < endPtr) {
        var u0 = heapOrArray[idx++];
        if (!(u0 & 128)) {
          str += String.fromCharCode(u0);
          continue
        }
        var u1 = heapOrArray[idx++] & 63;
        if ((u0 & 224) == 192) {
          str += String.fromCharCode((u0 & 31) << 6 | u1);
          continue
        }
        var u2 = heapOrArray[idx++] & 63;
        if ((u0 & 240) == 224) {
          u0 = (u0 & 15) << 12 | u1 << 6 | u2
        } else {
          u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heapOrArray[idx++] & 63
        }
        if (u0 < 65536) {
          str += String.fromCharCode(u0)
        } else {
          var ch = u0 - 65536;
          str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023)
        }
      }
      return str
    };
    var printChar = (stream, curr) => {
      var buffer = printCharBuffers[stream];
      if (curr === 0 || curr === 10) {
        (stream === 1 ? out : err)(UTF8ArrayToString(buffer));
        buffer.length = 0
      } else {
        buffer.push(curr)
      }
    };
    var _fd_write = (fd, iov, iovcnt, pnum) => {
      var num = 0;
      for (var i = 0; i < iovcnt; i++) {
        var ptr = HEAPU32[iov >> 2];
        var len = HEAPU32[iov + 4 >> 2];
        iov += 8;
        for (var j = 0; j < len; j++) {
          printChar(fd, HEAPU8[ptr + j])
        }
        num += len
      }
      HEAPU32[pnum >> 2] = num;
      return 0
    };
    {
      if (Module["noExitRuntime"]) noExitRuntime = Module["noExitRuntime"];
      if (Module["print"]) out = Module["print"];
      if (Module["printErr"]) err = Module["printErr"];
      if (Module["arguments"]) programArgs = Module["arguments"];
      if (Module["thisProgram"]) thisProgram = Module["thisProgram"];
      var preInit = Module["preInit"];
      if (preInit) {
        if (typeof preInit == "function") Module["preInit"] = preInit = [preInit];
        while (preInit.length > 0) {
          preInit.shift()()
        }
      }
    }
    var _malloc, _file_data_delete, _free, _emulator_set_builtin_palette, _emulator_was_ext_ram_updated, _emulator_read_state, _emulator_write_state, _emulator_read_ext_ram, _emulator_write_ext_ram, _emulator_delete, _emulator_get_PC, _emulator_get_A, _emulator_get_BC, _emulator_get_DE, _emulator_get_HL, _emulator_get_F, _emulator_get_SP, _emulator_set_PC, _emulator_get_wram_ptr, _emulator_get_hram_ptr, _emulator_read_mem, _emulator_write_mem, _emulator_set_breakpoint, _emulator_clear_breakpoints, _emulator_render_vram, _emulator_render_background, _emulator_get_banked_PC, _set_audio_channel_mute, _joypad_new, _joypad_delete, _rewind_append, _rewind_delete, _emulator_new_simple, _emulator_get_ticks_f64, _emulator_run_until_f64, _rewind_get_newest_ticks_f64, _rewind_get_oldest_ticks_f64, _emulator_set_default_joypad_callback, _emulator_set_bw_palette_simple, _rewind_new_simple, _rewind_begin, _emulator_set_rewind_joypad_callback, _rewind_to_ticks_wrapper, _rewind_end, _set_joyp_up, _set_joyp_down, _set_joyp_left, _set_joyp_right, _set_joyp_B, _set_joyp_A, _set_joyp_start, _set_joyp_select, _get_frame_buffer_ptr, _get_frame_buffer_size, _get_sgb_frame_buffer_ptr, _get_sgb_frame_buffer_size, _get_audio_buffer_ptr, _get_audio_buffer_capacity, _ext_ram_file_data_new, _state_file_data_new, _get_file_data_ptr, _get_file_data_size, _set_log_apu_writes, _get_apu_log_data_size, _get_apu_log_data_ptr, _reset_apu_log, memory, __indirect_function_table, wasmMemory;

    function assignWasmExports(wasmExports) {
      _malloc = Module["_malloc"] = wasmExports["f"];
      _file_data_delete = Module["_file_data_delete"] = wasmExports["g"];
      _free = Module["_free"] = wasmExports["h"];
      _emulator_set_builtin_palette = Module["_emulator_set_builtin_palette"] = wasmExports["i"];
      _emulator_was_ext_ram_updated = Module["_emulator_was_ext_ram_updated"] = wasmExports["j"];
      _emulator_read_state = Module["_emulator_read_state"] = wasmExports["k"];
      _emulator_write_state = Module["_emulator_write_state"] = wasmExports["l"];
      _emulator_read_ext_ram = Module["_emulator_read_ext_ram"] = wasmExports["m"];
      _emulator_write_ext_ram = Module["_emulator_write_ext_ram"] = wasmExports["n"];
      _emulator_delete = Module["_emulator_delete"] = wasmExports["o"];
      _emulator_get_PC = Module["_emulator_get_PC"] = wasmExports["p"];
      _emulator_get_A = Module["_emulator_get_A"] = wasmExports["q"];
      _emulator_get_BC = Module["_emulator_get_BC"] = wasmExports["r"];
      _emulator_get_DE = Module["_emulator_get_DE"] = wasmExports["s"];
      _emulator_get_HL = Module["_emulator_get_HL"] = wasmExports["t"];
      _emulator_get_F = Module["_emulator_get_F"] = wasmExports["u"];
      _emulator_get_SP = Module["_emulator_get_SP"] = wasmExports["v"];
      _emulator_set_PC = Module["_emulator_set_PC"] = wasmExports["w"];
      _emulator_get_wram_ptr = Module["_emulator_get_wram_ptr"] = wasmExports["x"];
      _emulator_get_hram_ptr = Module["_emulator_get_hram_ptr"] = wasmExports["y"];
      _emulator_read_mem = Module["_emulator_read_mem"] = wasmExports["z"];
      _emulator_write_mem = Module["_emulator_write_mem"] = wasmExports["A"];
      _emulator_set_breakpoint = Module["_emulator_set_breakpoint"] = wasmExports["B"];
      _emulator_clear_breakpoints = Module["_emulator_clear_breakpoints"] = wasmExports["C"];
      _emulator_render_vram = Module["_emulator_render_vram"] = wasmExports["D"];
      _emulator_render_background = Module["_emulator_render_background"] = wasmExports["E"];
      _emulator_get_banked_PC = Module["_emulator_get_banked_PC"] = wasmExports["F"];
      _set_audio_channel_mute = Module["_set_audio_channel_mute"] = wasmExports["G"];
      _joypad_new = Module["_joypad_new"] = wasmExports["H"];
      _joypad_delete = Module["_joypad_delete"] = wasmExports["I"];
      _rewind_append = Module["_rewind_append"] = wasmExports["J"];
      _rewind_delete = Module["_rewind_delete"] = wasmExports["K"];
      _emulator_new_simple = Module["_emulator_new_simple"] = wasmExports["L"];
      _emulator_get_ticks_f64 = Module["_emulator_get_ticks_f64"] = wasmExports["M"];
      _emulator_run_until_f64 = Module["_emulator_run_until_f64"] = wasmExports["N"];
      _rewind_get_newest_ticks_f64 = Module["_rewind_get_newest_ticks_f64"] = wasmExports["O"];
      _rewind_get_oldest_ticks_f64 = Module["_rewind_get_oldest_ticks_f64"] = wasmExports["P"];
      _emulator_set_default_joypad_callback = Module["_emulator_set_default_joypad_callback"] = wasmExports["Q"];
      _emulator_set_bw_palette_simple = Module["_emulator_set_bw_palette_simple"] = wasmExports["R"];
      _rewind_new_simple = Module["_rewind_new_simple"] = wasmExports["S"];
      _rewind_begin = Module["_rewind_begin"] = wasmExports["T"];
      _emulator_set_rewind_joypad_callback = Module["_emulator_set_rewind_joypad_callback"] = wasmExports["U"];
      _rewind_to_ticks_wrapper = Module["_rewind_to_ticks_wrapper"] = wasmExports["V"];
      _rewind_end = Module["_rewind_end"] = wasmExports["W"];
      _set_joyp_up = Module["_set_joyp_up"] = wasmExports["X"];
      _set_joyp_down = Module["_set_joyp_down"] = wasmExports["Y"];
      _set_joyp_left = Module["_set_joyp_left"] = wasmExports["Z"];
      _set_joyp_right = Module["_set_joyp_right"] = wasmExports["_"];
      _set_joyp_B = Module["_set_joyp_B"] = wasmExports["$"];
      _set_joyp_A = Module["_set_joyp_A"] = wasmExports["aa"];
      _set_joyp_start = Module["_set_joyp_start"] = wasmExports["ba"];
      _set_joyp_select = Module["_set_joyp_select"] = wasmExports["ca"];
      _get_frame_buffer_ptr = Module["_get_frame_buffer_ptr"] = wasmExports["da"];
      _get_frame_buffer_size = Module["_get_frame_buffer_size"] = wasmExports["ea"];
      _get_sgb_frame_buffer_ptr = Module["_get_sgb_frame_buffer_ptr"] = wasmExports["fa"];
      _get_sgb_frame_buffer_size = Module["_get_sgb_frame_buffer_size"] = wasmExports["ga"];
      _get_audio_buffer_ptr = Module["_get_audio_buffer_ptr"] = wasmExports["ha"];
      _get_audio_buffer_capacity = Module["_get_audio_buffer_capacity"] = wasmExports["ia"];
      _ext_ram_file_data_new = Module["_ext_ram_file_data_new"] = wasmExports["ja"];
      _state_file_data_new = Module["_state_file_data_new"] = wasmExports["ka"];
      _get_file_data_ptr = Module["_get_file_data_ptr"] = wasmExports["la"];
      _get_file_data_size = Module["_get_file_data_size"] = wasmExports["ma"];
      _set_log_apu_writes = Module["_set_log_apu_writes"] = wasmExports["na"];
      _get_apu_log_data_size = Module["_get_apu_log_data_size"] = wasmExports["oa"];
      _get_apu_log_data_ptr = Module["_get_apu_log_data_ptr"] = wasmExports["pa"];
      _reset_apu_log = Module["_reset_apu_log"] = wasmExports["qa"];
      memory = wasmMemory = wasmExports["d"];
      __indirect_function_table = wasmExports["__indirect_function_table"]
    }
    var wasmImports = {
      c: _emscripten_resize_heap,
      b: _exit,
      a: _fd_write
    };
    async function run() {
      preRun();
      var setStatus = Module["setStatus"];
      if (setStatus) {
        setStatus("Running...");
        await new Promise(resolve => setTimeout(resolve, 1));
        setTimeout(setStatus, 1, "")
      }
      if (ABORT) return;
      initRuntime();
      Module["onRuntimeInitialized"]?.();
      postRun()
    }
    var wasmExports;
    wasmExports = await createWasm();
    await run();;
    return Module
  }
})();
if (typeof exports === "object" && typeof module === "object") {
  module.exports = Binjgb;
  module.exports.default = Binjgb
} else if (typeof define === "function" && define["amd"]) define([], () => Binjgb);

  // ─────────────────────────────────────────── emulator cartridge (binjgb) ──
  // An 8th "cartridge" that runs a real Game Boy / Game Boy Color ROM on the
  // binjgb WASM core above. The core JS is inline (beautified, non-minified);
  // the wasm itself is fetched once from a version-pinned jsDelivr URL, checked
  // against a pinned SHA-256, and cached in GM storage for offline replay.
  // Bring your own .gb/.gbc — the last ROM you load is remembered across flights.
  // Exit back to the game menu with START+SELECT (Select is a real GB button here).
  function makeEmulator() {
    var W = 160, H = 144;
    var CPU_TICKS = 4194304, MAX_UPDATE_SEC = 5 / 60, AUDIO_FRAMES = 4096;
    var EVENT_NEW_FRAME = 1, EVENT_AUDIO_BUFFER_FULL = 2, EVENT_UNTIL_TICKS = 4;
    var WASM_URL = 'https://cdn.jsdelivr.net/gh/TheBreadHerring/[email protected]/binjgb.wasm';
    var WASM_SHA256 = '2613765623da527ee1525ec326835df8e5cc608f46a51f4c1604484fcc50606b';
    var WASM_CACHE_KEY = 'contraband.emu.wasm.' + WASM_SHA256;
    var ROM_DATA_KEY = 'contraband.emu.rom.data';
    var ROM_NAME_KEY = 'contraband.emu.rom.name';
    var INDEX_KEY = 'contraband.emu.index';
    var SPEED_KEY = 'contraband.emu.speed';

    var factory = (typeof Binjgb === 'function') ? Binjgb : (typeof window !== 'undefined' ? window.Binjgb : null);
    var api = null, mod = null, e = 0, joypadPtr = 0;
    var state = 'idle';           // idle | loading | needrom | running | menu | loadnew | restore | error
    var status = 'BOOTING';
    var imageData = null, fbPtr = 0, fbSize = 0;
    var leftover = 0, romId = '', romKey = 'contraband.emu.default', romName = 'ROM', saveAcc = 0, pickEl = null;
    var menuScreen = 'main', menuCursor = 0, comboLatch = false, speed = 1, palApplied = -1;
    var audioCtx = null, audioGain = null, audioTime = 0, sampleRate = 44100, audioBufPtr = 0, audioBufCap = 0;

    function setStatus(s) { status = s; }

    // ── GM / byte helpers ──
    function xhr() { try { if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest; } catch (x) {} try { if (window.GM && window.GM.xmlHttpRequest) return window.GM.xmlHttpRequest; } catch (x) {} return null; }
    function fetchBinary(url, cb, err) {
      var x = xhr(); if (!x) return err('no xhr');
      x({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: 30000,
        onload: function (r) {
          if (r.response && r.response.byteLength) return cb(r.response);
          var s = r.responseText || '', u = new Uint8Array(s.length);
          for (var k = 0; k < s.length; k++) u[k] = s.charCodeAt(k) & 0xff;
          cb(u.buffer);
        }, onerror: function () { err('failed'); }, ontimeout: function () { err('timeout'); } });
    }
    function u8ToB64(u8) { var s = '', C = 0x8000; for (var k = 0; k < u8.length; k += C) s += String.fromCharCode.apply(null, u8.subarray(k, k + C)); return btoa(s); }
    function b64ToU8(b64) { var bin = atob(b64), n = bin.length, u8 = new Uint8Array(n); for (var k = 0; k < n; k++) u8[k] = bin.charCodeAt(k); return u8; }
    function sha256hex(u8) {
      try {
        if (!(window.crypto && window.crypto.subtle)) return Promise.resolve(null);
        var ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
        return window.crypto.subtle.digest('SHA-256', ab).then(function (d) {
          var a = new Uint8Array(d), h = ''; for (var k = 0; k < a.length; k++) h += (a[k] >> 4).toString(16) + (a[k] & 15).toString(16); return h;
        }, function () { return null; });
      } catch (x) { return Promise.resolve(null); }
    }
    function verifyAndUse(u8, cb) {
      sha256hex(u8).then(function (hex) {
        if (hex && hex !== WASM_SHA256) { state = 'error'; setStatus('WASM HASH BAD'); return; }
        cb(u8);
      });
    }

    // ── wasm load: cache -> pinned CDN (sha256-checked) -> cache ──
    function loadWasm() {
      var c = null; try { c = api.storage.get(WASM_CACHE_KEY, null); } catch (x) {}
      if (c) { setStatus('LOADING CORE'); return verifyAndUse(b64ToU8(c), bootCore); }
      setStatus('FETCHING CORE');
      fetchBinary(WASM_URL, function (buf) {
        var u8 = new Uint8Array(buf);
        verifyAndUse(u8, function (v) { try { api.storage.set(WASM_CACHE_KEY, u8ToB64(v)); } catch (x) {} bootCore(v); });
      }, function () { state = 'error'; setStatus('CORE FETCH FAIL'); });
    }
    function bootCore(bytes) {
      setStatus('STARTING CORE');
      factory({
        wasmBinary: bytes,
        instantiateWasm: function (imports, cb) {
          try { WebAssembly.instantiate(bytes, imports).then(function (r) { cb(r.instance); }, function () { state = 'error'; setStatus('WASM ERR'); }); }
          catch (er) { state = 'error'; setStatus('WASM ERR'); }
          return {};
        }
      }).then(function (m) { mod = m; afterCore(); }, function () { state = 'error'; setStatus('CORE INIT FAIL'); });
    }
    function afterCore() {
      var romB64 = null; try { romB64 = api.storage.get(ROM_DATA_KEY, null); } catch (x) {}
      if (romB64) startRom(b64ToU8(romB64).buffer, api.storage.get(ROM_NAME_KEY, 'ROM'), false);
      else { state = 'needrom'; setStatus('NO ROM'); openPicker('rom'); }
    }

    // ── audio (shared with the toy's AudioContext; interleaved-stereo u8) ──
    function initAudio() {
      try { api.sound.unlock(); audioCtx = (api.sound.context ? api.sound.context() : null); } catch (x) { audioCtx = null; }
      sampleRate = (audioCtx && audioCtx.sampleRate) ? audioCtx.sampleRate : 44100;
      if (audioCtx && !audioGain) { try { audioGain = audioCtx.createGain(); audioGain.gain.value = 0.5; audioGain.connect(audioCtx.destination); } catch (x) { audioGain = null; } }
      audioTime = 0;
    }
    function pushAudio() {
      if (!audioCtx || !audioGain || speed !== 1 || !api.sound.isEnabled()) return;
      try {
        if (audioCtx.state === 'suspended') audioCtx.resume();
        var ab = audioCtx.createBuffer(2, AUDIO_FRAMES, sampleRate);
        var c0 = ab.getChannelData(0), c1 = ab.getChannelData(1);
        var src = new Uint8Array(mod.HEAP8.buffer, audioBufPtr, audioBufCap);
        for (var i = 0; i < AUDIO_FRAMES; i++) { c0[i] = src[2 * i] / 255; c1[i] = src[2 * i + 1] / 255; }
        var node = audioCtx.createBufferSource();
        node.buffer = ab; node.connect(audioGain);
        var now = audioCtx.currentTime;
        if (audioTime < now + 0.01) audioTime = now + 0.02;   // fell behind -> resync
        node.start(audioTime);
        audioTime += AUDIO_FRAMES / sampleRate;
      } catch (x) {}
    }

    // ── DMG palette: map the toy's PAL (dark d0 -> light d3) to binjgb's BW palette ──
    function hexToU32(hex) {
      var h = hex.charAt(0) === '#' ? hex.slice(1) : hex;
      var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
      return ((0xff << 24) | (b << 16) | (g << 8) | r) >>> 0;
    }
    function applyEmuPalette() {
      if (!mod || !e) return;
      try {
        var w = hexToU32(PAL.d3), lg = hexToU32(PAL.d2), dg = hexToU32(PAL.d1), bk = hexToU32(PAL.d0);
        for (var t = 0; t < 3; t++) mod._emulator_set_bw_palette_simple(e, t, w, lg, dg, bk);   // BGP / OBP0 / OBP1
      } catch (x) {}
    }

    // ── ROM lifecycle ──
    function hashRom(u8) { var h = 5381; for (var k = 0; k < u8.length; k += 997) h = ((h << 5) + h + u8[k]) | 0; return (h >>> 0).toString(36) + '_' + u8.length; }
    function startRom(buffer, name, remember) {
      try {
        stopEmu();
        var rom = new Uint8Array(buffer);
        romId = hashRom(rom);
        romKey = 'contraband.emu.' + romId + '.sram';
        romName = name || 'ROM';
        initAudio();                                   // context sample rate -> correct audio pitch
        var size = (rom.byteLength + 0x7fff) & ~0x7fff;
        var romPtr = mod._malloc(size);
        new Uint8Array(mod.HEAP8.buffer, romPtr, size).fill(0).set(rom);
        e = mod._emulator_new_simple(romPtr, size, sampleRate, AUDIO_FRAMES, 2);
        if (e === 0) { state = 'needrom'; setStatus('BAD ROM'); openPicker('rom'); return; }
        joypadPtr = mod._joypad_new();
        mod._emulator_set_default_joypad_callback(e, joypadPtr);
        audioBufPtr = mod._get_audio_buffer_ptr(e); audioBufCap = mod._get_audio_buffer_capacity(e);
        applyEmuPalette(); palApplied = FX.palette;
        if (!imageData) imageData = document.createElement('canvas').getContext('2d').createImageData(W, H);
        fbPtr = mod._get_frame_buffer_ptr(e); fbSize = mod._get_frame_buffer_size(e);
        loadSram();
        if (remember !== false) { try { api.storage.set(ROM_DATA_KEY, u8ToB64(rom)); api.storage.set(ROM_NAME_KEY, romName); } catch (x) {} }
        addRomToIndex(romId, romName);
        leftover = 0; saveAcc = 0; state = 'running'; setStatus('');
        removePicker();
      } catch (er) { state = 'error'; setStatus('ROM ERR'); }
    }
    function stopEmu() {
      if (mod && e) { try { if (mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} try { mod._emulator_delete(e); } catch (x) {} }
      if (mod && joypadPtr) { try { mod._joypad_delete(joypadPtr); } catch (x) {} }
      e = 0; joypadPtr = 0;
    }
    function saveSram() {
      try {
        var fdp = mod._ext_ram_file_data_new(e);
        var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
        mod._emulator_write_ext_ram(e, fdp);
        var bytes = Array.prototype.slice.call(new Uint8Array(mod.HEAP8.buffer, ptr, sz));
        mod._file_data_delete(fdp);
        api.storage.set(romKey, bytes);
      } catch (x) {}
    }
    function loadSram() {
      try {
        var saved = api.storage.get(romKey, null); if (!saved || !saved.length) return;
        var fdp = mod._ext_ram_file_data_new(e);
        var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
        if (sz === saved.length) { new Uint8Array(mod.HEAP8.buffer, ptr, sz).set(saved); mod._emulator_read_ext_ram(e, fdp); }
        mod._file_data_delete(fdp);
      } catch (x) {}
    }

    // ── save states (3 slots per ROM) ──
    function stateKey(slot) { return 'contraband.emu.' + romId + '.st' + slot; }
    function slotUsed(slot) { try { var v = api.storage.get(stateKey(slot), null); return !!(v && v.length); } catch (x) { return false; } }
    function saveState(slot) {
      if (!mod || !e) return;
      try {
        var fdp = mod._state_file_data_new(e);
        mod._emulator_write_state(e, fdp);
        var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
        var bytes = Array.prototype.slice.call(new Uint8Array(mod.HEAP8.buffer, ptr, sz));
        mod._file_data_delete(fdp);
        api.storage.set(stateKey(slot), bytes);
        setStatus('SAVED SLOT ' + slot);
      } catch (x) { setStatus('SAVE FAILED'); }
    }
    function loadState(slot) {
      if (!mod || !e) return false;
      try {
        var saved = api.storage.get(stateKey(slot), null);
        if (!saved || !saved.length) { setStatus('SLOT ' + slot + ' EMPTY'); return false; }
        var fdp = mod._state_file_data_new(e);
        var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp), ok = false;
        if (sz === saved.length) { new Uint8Array(mod.HEAP8.buffer, ptr, sz).set(saved); mod._emulator_read_state(e, fdp); ok = true; }
        mod._file_data_delete(fdp);
        setStatus(ok ? 'LOADED SLOT ' + slot : 'SLOT ' + slot + ' MISMATCH');
        return ok;
      } catch (x) { setStatus('LOAD FAILED'); return false; }
    }

    // ── ROM index (GMforPDA has no listValues, so track keys ourselves) ──
    function getIndex() { try { var v = api.storage.get(INDEX_KEY, null); return (v && v.length) ? v : []; } catch (x) { return []; } }
    function addRomToIndex(id, name) {
      try {
        var idx = getIndex(), found = false;
        for (var k = 0; k < idx.length; k++) if (idx[k].id === id) { idx[k].name = name; found = true; break; }
        if (!found) idx.push({ id: id, name: name });
        api.storage.set(INDEX_KEY, idx);
      } catch (x) {}
    }

    // ── backup / restore: SRAM + save states for every known ROM (not the ROM or wasm) ──
    function buildBackup() {
      var idx = getIndex(), data = {};
      for (var k = 0; k < idx.length; k++) {
        var id = idx[k].id;
        var sram = api.storage.get('contraband.emu.' + id + '.sram', null);
        if (sram && sram.length) data['contraband.emu.' + id + '.sram'] = sram;
        for (var s = 1; s <= 3; s++) {
          var st = api.storage.get('contraband.emu.' + id + '.st' + s, null);
          if (st && st.length) data['contraband.emu.' + id + '.st' + s] = st;
        }
      }
      return JSON.stringify({ app: 'contraband', v: 1, when: Date.now(), roms: idx, data: data });
    }
    function downloadFile(name, text) {
      var blob = new Blob([text], { type: 'application/json' });
      var url = URL.createObjectURL(blob);
      var a = document.createElement('a'); a.href = url; a.download = name; a.rel = 'noopener';
      (document.body || document.documentElement).appendChild(a); a.click();
      setTimeout(function () { try { a.parentNode && a.parentNode.removeChild(a); URL.revokeObjectURL(url); } catch (x) {} }, 1500);
    }
    function exportBackup() {
      var json = buildBackup(), ok = [];
      try { downloadFile('contraband-saves.json', json); ok.push('FILE'); } catch (x) {}
      try { if (json.length < 300000 && typeof GM_setClipboard === 'function') { GM_setClipboard(json); ok.push('CLIP'); } } catch (x) {}
      setStatus(ok.length ? 'BACKUP: ' + ok.join('+') : 'BACKUP FAILED');
    }
    function importBackup(text) {
      try {
        var obj = JSON.parse(text);
        if (!obj || obj.app !== 'contraband' || !obj.data) { setStatus('BAD BACKUP FILE'); return; }
        var n = 0, key;
        for (key in obj.data) { if (obj.data.hasOwnProperty(key) && key.indexOf('contraband.emu.') === 0 && key.indexOf('.wasm.') < 0 && key.indexOf('.rom.') < 0) { api.storage.set(key, obj.data[key]); n++; } }
        if (obj.roms && obj.roms.length) {
          var idx = getIndex();
          for (var k = 0; k < obj.roms.length; k++) { var r = obj.roms[k], f = false; for (var j = 0; j < idx.length; j++) if (idx[j].id === r.id) { f = true; break; } if (!f) idx.push(r); }
          api.storage.set(INDEX_KEY, idx);
        }
        setStatus('RESTORED ' + n + ' SAVES');
        if (mod && e && romId) loadSram();
      } catch (x) { setStatus('RESTORE FAILED'); }
    }

    // ── file picker overlay (mode 'rom' | 'backup'); tap the LCD to load ──
    function openPicker(mode) {
      if (pickEl) return;
      var root = document.getElementById(ROOT_ID); if (!root) return;
      var scr = root.querySelector('.cb-screen');
      var host = (scr && scr.parentNode) ? scr.parentNode : root;
      try { if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; } catch (x) {}
      var inp = document.createElement('input');
      inp.type = 'file';
      inp.accept = (mode === 'backup') ? '.json,application/json,*/*' : '.gb,.gbc,application/octet-stream';
      inp.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer;z-index:50;';
      inp.addEventListener('change', function () {
        var f = inp.files && inp.files[0]; if (!f) return;
        var fr = new FileReader();
        if (mode === 'backup') { fr.onload = function () { importBackup(fr.result); removePicker(); state = 'menu'; menuScreen = 'main'; menuCursor = 0; }; fr.readAsText(f); }
        else { fr.onload = function () { startRom(fr.result, f.name, true); }; fr.readAsArrayBuffer(f); }
      });
      host.appendChild(inp); pickEl = inp;
    }
    function removePicker() { if (pickEl && pickEl.parentNode) pickEl.parentNode.removeChild(pickEl); pickEl = null; }

    // ── emulation step (speed multiplies the tick budget; audio only at 1x) ──
    function run(dt) {
      var seconds = Math.min(dt / 1000, MAX_UPDATE_SEC) * speed;
      var until = mod._emulator_get_ticks_f64(e) + seconds * CPU_TICKS - leftover;
      var guard = 0;
      while (guard++ < 64) {
        var ev = mod._emulator_run_until_f64(e, until);
        if (ev & EVENT_NEW_FRAME) imageData.data.set(new Uint8Array(mod.HEAP8.buffer, fbPtr, fbSize));
        if (ev & EVENT_AUDIO_BUFFER_FULL) pushAudio();
        if (ev & EVENT_UNTIL_TICKS) break;
      }
      leftover = (mod._emulator_get_ticks_f64(e) - until) | 0;
    }

    // ── pause menu model ──
    function menuItems() {
      if (menuScreen === 'save') return ['SLOT 1 ' + (slotUsed(1) ? '[USED]' : '[----]'), 'SLOT 2 ' + (slotUsed(2) ? '[USED]' : '[----]'), 'SLOT 3 ' + (slotUsed(3) ? '[USED]' : '[----]'), 'BACK'];
      if (menuScreen === 'load') return ['SLOT 1 ' + (slotUsed(1) ? '[USED]' : '[EMPTY]'), 'SLOT 2 ' + (slotUsed(2) ? '[USED]' : '[EMPTY]'), 'SLOT 3 ' + (slotUsed(3) ? '[USED]' : '[EMPTY]'), 'BACK'];
      return ['RESUME', 'SAVE STATE', 'LOAD STATE', 'LOAD ROM', 'SPEED ' + speed + 'x', 'BACKUP', 'RESTORE', 'EXIT'];
    }
    function clearJoyp() { if (!mod || !e) return; try { mod._set_joyp_up(e, 0); mod._set_joyp_down(e, 0); mod._set_joyp_left(e, 0); mod._set_joyp_right(e, 0); mod._set_joyp_A(e, 0); mod._set_joyp_B(e, 0); mod._set_joyp_start(e, 0); mod._set_joyp_select(e, 0); } catch (x) {} }
    function enterMenu() { clearJoyp(); menuScreen = 'main'; menuCursor = 0; setStatus(''); state = 'menu'; }
    function exitToGames() {
      try { if (mod && e && mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {}
      removePicker(); clearJoyp();
      if (state === 'menu' || state === 'loadnew' || state === 'restore') state = 'running';
      if (api && api.exitMenu) api.exitMenu();
    }
    function chooseMain(i) {
      if (i === 0) state = 'running';
      else if (i === 1) { menuScreen = 'save'; menuCursor = 0; setStatus(''); }
      else if (i === 2) { menuScreen = 'load'; menuCursor = 0; setStatus(''); }
      else if (i === 3) { state = 'loadnew'; setStatus('LOAD ROM'); openPicker('rom'); }
      else if (i === 4) { speed = speed >= 3 ? 1 : speed + 1; try { api.storage.set(SPEED_KEY, speed); } catch (x) {} setStatus('SPEED ' + speed + 'x'); }
      else if (i === 5) exportBackup();
      else if (i === 6) { state = 'restore'; setStatus('LOAD BACKUP'); openPicker('backup'); }
      else exitToGames();
    }
    function chooseSlot(i) {
      if (i === 3) { menuScreen = 'main'; menuCursor = 0; setStatus(''); return; }
      var slot = i + 1;
      if (menuScreen === 'save') { saveState(slot); menuScreen = 'main'; menuCursor = 0; }
      else if (loadState(slot)) state = 'running';
    }

    function drawMenu(g) {
      g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
      g.textAlign = 'center'; g.textBaseline = 'top';
      g.fillStyle = PAL.d0; g.font = 'bold 12px "Courier New", monospace';
      g.fillText(menuScreen === 'save' ? 'SAVE STATE' : menuScreen === 'load' ? 'LOAD STATE' : 'EMULATOR', W / 2, 4);
      g.fillStyle = PAL.d1; g.fillRect(14, 18, W - 28, 1);
      var items = menuItems(), top = 22, rowH = (items.length > 5 ? 12 : 15);
      g.font = '9px "Courier New", monospace';
      for (var k = 0; k < items.length; k++) {
        var y = top + k * rowH;
        if (k === menuCursor) { g.fillStyle = PAL.d1; g.fillRect(8, y - 2, W - 16, 11); g.fillStyle = PAL.d3; }
        else g.fillStyle = PAL.d0;
        g.fillText(items[k], W / 2, y);
      }
      g.fillStyle = PAL.d1; g.font = '7px "Courier New", monospace';
      g.fillText(status ? status : 'A OK   B BACK   START+SEL EXIT', W / 2, H - 8);
      g.textAlign = 'left';
    }
    function drawStatus(g) {
      g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
      g.textAlign = 'center'; g.textBaseline = 'middle';
      g.fillStyle = PAL.d0; g.font = 'bold 12px "Courier New", monospace';
      g.fillText('EMULATOR', W / 2, H / 2 - 22);
      g.fillStyle = PAL.d1; g.font = '9px "Courier New", monospace';
      g.fillText(status || '', W / 2, H / 2);
      if (state === 'needrom' || state === 'error' || state === 'loadnew' || state === 'restore') {
        g.fillStyle = PAL.d2; g.font = '8px "Courier New", monospace';
        g.fillText(state === 'restore' ? 'TAP: LOAD BACKUP FILE' : 'TAP SCREEN TO LOAD', W / 2, H / 2 + 18);
        g.fillText((state === 'loadnew' || state === 'restore') ? 'B = BACK' : '.gb / .gbc ROM', W / 2, H / 2 + 30);
      }
      g.textAlign = 'left'; g.textBaseline = 'top';
    }

    return {
      id: 'emu', name: 'EMULATOR', usesSelect: true,
      init: function (a) {
        api = a;
        try { speed = a.storage.get(SPEED_KEY, 1) | 0; if (speed < 1 || speed > 3) speed = 1; } catch (x) { speed = 1; }
        if (typeof factory !== 'function') { state = 'error'; setStatus('CORE MISSING'); return; }
        state = 'loading'; setStatus('LOADING'); loadWasm();
      },
      reset: function () { comboLatch = false; if (state === 'needrom') openPicker('rom'); },
      isPlaying: function () { return state === 'running'; },
      onExit: function () { try { if (mod && e && mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} removePicker(); },
      update: function (dt) {
        var i = api.input;
        var combo = i.isDown('start') && i.isDown('select');
        if (state === 'running') {
          if (combo && !comboLatch) { comboLatch = true; enterMenu(); return; }   // START+SELECT -> pause menu
          if (!combo) comboLatch = false;
          if (FX.palette !== palApplied) { applyEmuPalette(); palApplied = FX.palette; }   // live palette change
          mod._set_joyp_up(e, i.isDown('up') ? 1 : 0);
          mod._set_joyp_down(e, i.isDown('down') ? 1 : 0);
          mod._set_joyp_left(e, i.isDown('left') ? 1 : 0);
          mod._set_joyp_right(e, i.isDown('right') ? 1 : 0);
          mod._set_joyp_A(e, i.isDown('a') ? 1 : 0);
          mod._set_joyp_B(e, i.isDown('b') ? 1 : 0);
          mod._set_joyp_start(e, i.isDown('start') ? 1 : 0);
          mod._set_joyp_select(e, i.isDown('select') ? 1 : 0);
          run(dt);
          saveAcc += dt;
          if (saveAcc >= 4000) { saveAcc = 0; try { if (mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} }
          return;
        }
        if (combo && !comboLatch) { comboLatch = true; exitToGames(); return; }
        if (!combo) comboLatch = false;
        if (state === 'menu') {
          var items = menuItems();
          if (i.justPressed('up')) { menuCursor = (menuCursor + items.length - 1) % items.length; Sound.play('select'); }
          if (i.justPressed('down')) { menuCursor = (menuCursor + 1) % items.length; Sound.play('select'); }
          if (i.justPressed('b') && menuScreen !== 'main') { menuScreen = 'main'; menuCursor = 0; setStatus(''); Sound.play('select'); }
          if (i.justPressed('a') || i.justPressed('start')) {
            Sound.play('confirm');
            if (menuScreen === 'main') chooseMain(menuCursor); else chooseSlot(menuCursor);
          }
        } else if (state === 'loadnew' || state === 'restore') {
          if (i.justPressed('b')) { removePicker(); state = 'menu'; menuScreen = 'main'; menuCursor = 0; Sound.play('select'); }
        } else if (state === 'needrom') {
          if (i.justPressed('a') || i.justPressed('start')) openPicker('rom');
        }
      },
      render: function (g) {
        if (state === 'running' && imageData) g.putImageData(imageData, 0, 0);
        else if (state === 'menu') drawMenu(g);
        else drawStatus(g);
      }
    };
  }

  var CARTRIDGES = [makeSnake(), makeBreakout(), makeTetris(), make2048(), makeFlappy(), makePong(), makeSimon(), makeEmulator()];

  // ────────────────────────────────────────────────────────────── engine ──
  // Drives the active cartridge: fixed input read, update(dt), render, clear edges.
  function createEngine(canvas, input, setBrand, applyFx) {
    var screen = canvas.getContext('2d');
    screen.imageSmoothingEnabled = false;
    var buf = document.createElement('canvas');
    buf.width = canvas.width; buf.height = canvas.height;
    var g = buf.getContext('2d');           // games/menu draw here; blitted below with LCD ghost
    g.imageSmoothingEnabled = false;
    var carts = CARTRIDGES;
    var raf = 0, last = 0, running = false, booted = false;
    // current: -5 hi-scores, -4 initials, -3 splash, -2 settings, -1 menu, >=0 game
    var current = -1, cursor = 0, setCursor = 0, initedSet = {}, paused = false;
    var splashT = 0, msg = '', msgT = 0;
    var initials = [0, 0, 0], iCursor = 0, hiIdx = -1, hiScore = 0;
    var LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.-! ';
    var SPLASH_MS = 1300, LAST_KEY = 'contraband.lastGame', SNAKE_WRAP = 'contraband.snake.wrap';
    var api = { W: canvas.width, H: canvas.height, input: input, storage: Storage, sound: Sound, haptic: Haptics, exitMenu: toMenu };

    function idxOfId(id) { for (var i = 0; i < carts.length; i++) if (carts[i].id === id) return i; return -1; }
    function toMenu() { current = -1; paused = false; if (setBrand) setBrand('SELECT GAME'); }
    function toSplash() { current = -3; splashT = 0; }
    function toSettings() { current = -2; setCursor = 0; if (setBrand) setBrand('SETTINGS'); }
    function toScores() { current = -5; }
    function toInitials(idx, score) {
      current = -4; hiIdx = idx; hiScore = score; iCursor = 0; initials = [0, 0, 0];
      var prev = ('' + Storage.get('contraband.' + carts[idx].id + '.hiname', 'AAA')).toUpperCase();
      for (var k = 0; k < 3; k++) { var p = LETTERS.indexOf(prev.charAt(k)); initials[k] = p < 0 ? 0 : p; }
    }
    function launch(idx) {
      current = idx; paused = false;
      var c = carts[idx];
      if (!initedSet[idx]) { c.init(api); initedSet[idx] = true; }
      else if (c.reset) c.reset();
      Storage.set(LAST_KEY, c.id);          // remember for next boot
      if (setBrand) setBrand(c.name);
    }
    function resetScores() {
      for (var i = 0; i < carts.length; i++) {
        Storage.set('contraband.' + carts[i].id + '.hiscore', 0);
        Storage.set('contraband.' + carts[i].id + '.hiname', 'AAA');
      }
      initedSet = {};                        // force re-init so cached hi-scores reload as 0
    }
    function anyPressed() {
      var A = ['a', 'b', 'start', 'select', 'up', 'down', 'left', 'right'];
      for (var i = 0; i < A.length; i++) if (input.justPressed(A[i])) return true;
      return false;
    }

    // ── game menu (games + a SETTINGS row) ──
    function menuCount() { return carts.length + 1; }
    function updateMenu() {
      if (input.justPressed('up')) { cursor = (cursor - 1 + menuCount()) % menuCount(); Sound.play('select'); }
      if (input.justPressed('down')) { cursor = (cursor + 1) % menuCount(); Sound.play('select'); }
      if (input.justPressed('a') || input.justPressed('start')) {
        Sound.play('confirm');
        if (cursor === carts.length) toSettings(); else launch(cursor);
      }
    }
    function renderMenu() {
      g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
      g.textBaseline = 'top'; g.textAlign = 'center';
      g.fillStyle = PAL.d0; g.font = 'bold 14px "Courier New", monospace';
      g.fillText('CONTRABAND', api.W / 2, 5);
      g.fillStyle = PAL.d1; g.fillRect(12, 20, api.W - 24, 1);
      g.font = '9px "Courier New", monospace';
      var top = 22, rowH = 12, n = menuCount();   // tighter so 8 games + SETTINGS clear the footer
      for (var i = 0; i < n; i++) {
        var y = top + i * rowH, label = (i === carts.length) ? 'SETTINGS' : carts[i].name;
        if (i === cursor) { g.fillStyle = PAL.d1; g.fillRect(14, y - 2, api.W - 28, 12); g.fillStyle = PAL.d3; }
        else g.fillStyle = PAL.d0;
        g.fillText(label, api.W / 2, y);
      }
      g.fillStyle = PAL.d1; g.font = '8px "Courier New", monospace';
      g.fillText('A PLAY   SELECT MENU', api.W / 2, api.H - 10);
      g.textAlign = 'left';
    }

    // ── settings ──
    var SETLABELS = ['SOUND', 'LCD FX', 'PALETTE', 'HAPTICS', 'SNAKE', 'HI-SCORES', 'RESET SCORES', 'BACK'];
    function settingsVal(i) {
      if (i === 0) return Sound.isEnabled() ? 'ON' : 'OFF';
      if (i === 1) return FX.crt ? 'ON' : 'OFF';
      if (i === 2) return PALETTES[FX.palette].name;
      if (i === 3) return Haptics.isEnabled() ? 'ON' : 'OFF';
      if (i === 4) return Storage.get(SNAKE_WRAP, false) ? 'WRAP' : 'WALLS';
      return '';
    }
    function updateSettings() {
      var n = SETLABELS.length;
      if (input.justPressed('up')) { setCursor = (setCursor - 1 + n) % n; Sound.play('select'); }
      if (input.justPressed('down')) { setCursor = (setCursor + 1) % n; Sound.play('select'); }
      if (input.justPressed('b') || input.justPressed('select')) { Sound.play('select'); toMenu(); return; }
      if (input.justPressed('a') || input.justPressed('start')) {
        Sound.play('confirm');
        if (setCursor === 0) Sound.setEnabled(!Sound.isEnabled());
        else if (setCursor === 1) { FX.crt = !FX.crt; Storage.set('contraband.crt', FX.crt); if (applyFx) applyFx(); }
        else if (setCursor === 2) { FX.palette = (FX.palette + 1) % PALETTES.length; Storage.set('contraband.palette', FX.palette); applyPalette(FX.palette); }
        else if (setCursor === 3) Haptics.setEnabled(!Haptics.isEnabled());
        else if (setCursor === 4) Storage.set(SNAKE_WRAP, !Storage.get(SNAKE_WRAP, false));
        else if (setCursor === 5) toScores();
        else if (setCursor === 6) { resetScores(); msg = 'SCORES CLEARED'; msgT = 1100; }
        else toMenu();
      }
    }
    function renderSettings() {
      g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
      g.textBaseline = 'top'; g.textAlign = 'center';
      g.fillStyle = PAL.d0; g.font = 'bold 13px "Courier New", monospace';
      g.fillText('SETTINGS', api.W / 2, 3);
      g.fillStyle = PAL.d1; g.fillRect(10, 17, api.W - 20, 1);
      var top = 23, rowH = 13;
      g.font = '9px "Courier New", monospace';
      for (var i = 0; i < SETLABELS.length; i++) {
        var y = top + i * rowH, sel = i === setCursor;
        if (sel) { g.fillStyle = PAL.d1; g.fillRect(6, y - 2, api.W - 12, 12); }
        g.textAlign = 'left'; g.fillStyle = sel ? PAL.d3 : PAL.d0; g.fillText(SETLABELS[i], 12, y);
        g.textAlign = 'right'; g.fillText(settingsVal(i), api.W - 12, y);
      }
      g.textAlign = 'center'; g.font = '8px "Courier New", monospace'; g.fillStyle = PAL.d1;
      g.fillText(msgT > 0 ? msg : 'A TOGGLE   SEL BACK', api.W / 2, api.H - 9);
      g.textAlign = 'left';
    }

    // ── hi-scores table ──
    function updateScores() {
      if (input.justPressed('a') || input.justPressed('b') || input.justPressed('start') || input.justPressed('select')) { Sound.play('select'); toSettings(); }
    }
    function renderScores() {
      g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
      g.textBaseline = 'top';
      g.textAlign = 'center'; g.fillStyle = PAL.d0; g.font = 'bold 13px "Courier New", monospace';
      g.fillText('HI-SCORES', api.W / 2, 3);
      g.fillStyle = PAL.d1; g.fillRect(10, 17, api.W - 20, 1);
      g.font = '9px "Courier New", monospace';
      var top = 24, rowH = 14;
      for (var i = 0; i < carts.length; i++) {
        if (carts[i].usesSelect) continue;   // emulator isn't a scored game
        var y = top + i * rowH;
        var sc = Storage.get('contraband.' + carts[i].id + '.hiscore', 0) | 0;
        var nm = ('' + Storage.get('contraband.' + carts[i].id + '.hiname', 'AAA')).toUpperCase();
        g.textAlign = 'left'; g.fillStyle = PAL.d0; g.fillText(carts[i].name, 12, y);
        g.textAlign = 'right'; g.fillStyle = PAL.d1; g.fillText(nm + '  ' + sc, api.W - 12, y);
      }
      g.textAlign = 'center'; g.font = '8px "Courier New", monospace'; g.fillStyle = PAL.d1;
      g.fillText('ANY KEY = BACK', api.W / 2, api.H - 9);
      g.textAlign = 'left';
    }

    // ── initials entry (shown when a game reports a new record) ──
    function commitInitials() {
      var name = LETTERS.charAt(initials[0]) + LETTERS.charAt(initials[1]) + LETTERS.charAt(initials[2]);
      Storage.set('contraband.' + carts[hiIdx].id + '.hiname', name);
      Sound.play('confirm');
      current = hiIdx;   // back to the game's over screen
    }
    function updateInitials() {
      if (input.justPressed('up')) { initials[iCursor] = (initials[iCursor] + 1) % LETTERS.length; Sound.play('select'); }
      if (input.justPressed('down')) { initials[iCursor] = (initials[iCursor] - 1 + LETTERS.length) % LETTERS.length; Sound.play('select'); }
      if (input.justPressed('left')) { iCursor = (iCursor + 2) % 3; Sound.play('select'); }
      if (input.justPressed('right')) { iCursor = (iCursor + 1) % 3; Sound.play('select'); }
      if (input.justPressed('a') || input.justPressed('start')) {
        if (iCursor < 2) { iCursor++; Sound.play('select'); } else commitInitials();
      }
    }
    function renderInitials() {
      g.fillStyle = PAL.d0; g.fillRect(0, 0, api.W, api.H);
      g.textAlign = 'center'; g.textBaseline = 'middle';
      g.fillStyle = PAL.d3; g.font = 'bold 13px "Courier New", monospace';
      g.fillText('NEW RECORD!', api.W / 2, 26);
      g.fillStyle = PAL.d2; g.font = '9px "Courier New", monospace';
      g.fillText('SCORE ' + hiScore, api.W / 2, 44);
      g.font = 'bold 26px "Courier New", monospace';
      for (var k = 0; k < 3; k++) {
        var x = api.W / 2 + (k - 1) * 30;
        g.fillStyle = (k === iCursor) ? PAL.d3 : PAL.d2;
        g.fillText(LETTERS.charAt(initials[k]), x, api.H / 2 + 6);
        if (k === iCursor) { g.fillStyle = PAL.d3; g.fillRect(x - 10, api.H / 2 + 24, 20, 2); }
      }
      g.fillStyle = PAL.d1; g.font = '7px "Courier New", monospace';
      g.fillText('UP/DN LETTER  L/R MOVE  A OK', api.W / 2, api.H - 12);
      g.textAlign = 'left'; g.textBaseline = 'top';
    }

    // ── bootleg boot splash ──
    function updateSplash(dt) {
      splashT += dt;
      if (splashT >= SPLASH_MS || anyPressed()) {
        var id = Storage.get(LAST_KEY, null), li = id ? idxOfId(id) : -1;
        if (li >= 0) launch(li); else toMenu();
      }
    }
    function renderSplash() {
      g.fillStyle = PAL.d0; g.fillRect(0, 0, api.W, api.H);
      g.textAlign = 'center'; g.textBaseline = 'middle';
      var lit = FX.reduced ? true : (Math.floor(splashT / 110) % 9) !== 0;   // occasional flicker
      g.fillStyle = lit ? PAL.d3 : PAL.d1;
      g.font = 'bold 20px "Courier New", monospace';
      g.fillText('CONTRABAND', api.W / 2, api.H / 2 - 12);
      g.fillStyle = PAL.d2; g.font = '7px "Courier New", monospace';
      g.fillText('* UNLICENSED * NOT FOR RESALE', api.W / 2, api.H / 2 + 12);
      g.fillText('(C) 20XX  BREADH IND.', api.W / 2, api.H / 2 + 26);
      g.textAlign = 'left'; g.textBaseline = 'top';
    }

    function renderPause() {
      g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(0, 0, api.W, api.H);
      g.textAlign = 'center'; g.textBaseline = 'middle';
      g.fillStyle = PAL.d3; g.font = 'bold 16px "Courier New", monospace';
      g.fillText('PAUSED', api.W / 2, api.H / 2 - 6);
      g.fillStyle = PAL.d2; g.font = '8px "Courier New", monospace';
      g.fillText('START = RESUME', api.W / 2, api.H / 2 + 12);
      g.textAlign = 'left'; g.textBaseline = 'top';
    }

    function frame(now) {
      if (!running) return;
      raf = window.requestAnimationFrame(frame);
      try {
        var dt = now - last; last = now;
        if (dt < 0) dt = 0; if (dt > 100) dt = 100; // clamp after tab-throttle
        if (msgT > 0) msgT -= dt;
        if (current === -3) updateSplash(dt);
        else if (current === -2) updateSettings();
        else if (current === -1) updateMenu();
        else if (current === -4) updateInitials();
        else if (current === -5) updateScores();
        else {
          var cart = carts[current];
          if (cart.usesSelect) { cart.update(dt); }                                  // emulator owns all input incl. its own menu + exit
          else if (input.justPressed('select')) { Sound.play('select'); toMenu(); }      // eject to menu
          else if (paused) { if (input.justPressed('start')) { paused = false; Sound.play('pause'); } }
          else if (input.justPressed('start') && cart.isPlaying && cart.isPlaying()) { paused = true; Sound.play('pause'); }
          else {
            cart.update(dt);                                                          // Start pauses only in play
            if (cart.tookHigh) { var hs = cart.tookHigh(); if (hs >= 0) toInitials(current, hs); } // record → initials
          }
        }
        if (current === -3) renderSplash();
        else if (current === -2) renderSettings();
        else if (current === -1) renderMenu();
        else if (current === -4) renderInitials();
        else if (current === -5) renderScores();
        else { carts[current].render(g); if (paused) renderPause(); }
        // composite the buffer onto the visible LCD; a soft ghost trail when LCD FX is on
        screen.globalAlpha = (FX.crt && !FX.reduced) ? 0.6 : 1;
        screen.drawImage(buf, 0, 0);
        screen.globalAlpha = 1;
      } catch (err) {
        try { if (window.console && console.warn) console.warn('[Contraband] frame error (contained):', err); } catch (e2) {}
      } finally {
        input.endFrame();   // always clear input edges so nothing sticks
      }
    }

    function pause() {
      running = false;
      if (raf) window.cancelAnimationFrame(raf);
      raf = 0;
    }
    function resume() {
      if (running) return;
      running = true;
      last = performance.now();
      raf = window.requestAnimationFrame(frame);
    }
    function onVisibility() {
      if (document.hidden && current >= 0) {
        var c = carts[current];
        if (!c.usesSelect && c.isPlaying && c.isPlaying()) paused = true;   // emulator halts naturally when RAF stops
      }
    }
    document.addEventListener('visibilitychange', onVisibility);

    return {
      start: function () { if (!booted) { booted = true; toSplash(); } resume(); },
      pause: pause,   // stop the loop but keep game state (used when hidden)
      resume: resume,
      stop: pause,
      destroy: function () { document.removeEventListener('visibilitychange', onVisibility); pause(); }
    };
  }

  // ─────────────────────────────────────────────────────────────── styles ──
  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    var css = [
      '#' + ROOT_ID + '{position:fixed;right:12px;bottom:16px;z-index:2147483000;',
      'font-family:"Courier New",monospace;-webkit-user-select:none;user-select:none;',
      'touch-action:none;color:#e8ecd6;transform:rotate(-1.1deg) scale(var(--cb-scale,1));transform-origin:50% 50%;}',
      '@media (max-width:784px){#' + ROOT_ID + '{right:8px;',
      'bottom:calc(env(safe-area-inset-bottom,0px) + 64px);}}',

      '#' + ROOT_ID + ' .cb-titlebar{display:flex;align-items:center;justify-content:space-between;',
      'background:#1f2419;border:2px solid #0d1109;border-bottom:none;border-radius:10px 10px 0 0;',
      'padding:4px 8px;cursor:grab;letter-spacing:1px;font-size:11px;color:' + PAL.d3 + ';}',
      '#' + ROOT_ID + ' .cb-titlebar:active{cursor:grabbing;}',
      '#' + ROOT_ID + ' .cb-title{font-weight:bold;}',
      '#' + ROOT_ID + ' .cb-collapse{background:none;border:none;color:' + PAL.d2 + ';',
      'font-size:14px;line-height:1;cursor:pointer;padding:0 2px;}',

      // Weathered, grimy bootleg shell.
      '#' + ROOT_ID + ' .cb-gb{position:relative;width:196px;',
      'background:radial-gradient(130% 90% at 28% 8%,rgba(255,255,255,.16),rgba(255,255,255,0) 42%),',
      'linear-gradient(158deg,#c6c8b1 0%,#b4b69d 52%,#a2a48d 100%);',
      'border:2px solid #0d1109;border-top:none;border-radius:0 0 14px 34px 14px;padding:10px 12px 14px;',
      'box-shadow:0 9px 26px rgba(0,0,0,.5),inset 0 0 18px rgba(50,52,34,.35),inset 0 2px 0 rgba(255,255,255,.12);}',
      // grime smudges (behind the controls)
      '#' + ROOT_ID + ' .cb-gb::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;',
      'background:radial-gradient(10px 4px at 22% 26%,rgba(40,40,25,.18),transparent 70%),',
      'radial-gradient(16px 6px at 72% 15%,rgba(40,40,25,.14),transparent 70%),',
      'radial-gradient(22px 9px at 58% 86%,rgba(40,40,25,.16),transparent 70%),',
      'radial-gradient(6px 6px at 86% 64%,rgba(40,40,25,.20),transparent 70%),',
      'radial-gradient(5px 5px at 13% 74%,rgba(40,40,25,.18),transparent 70%);}',
      // hairline scratches across the face
      '#' + ROOT_ID + ' .cb-gb::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;',
      'background:linear-gradient(66deg,transparent 40.6%,rgba(255,255,255,.18) 41%,transparent 41.4%),',
      'linear-gradient(57deg,transparent 65.7%,rgba(0,0,0,.14) 66%,transparent 66.3%);}',

      '#' + ROOT_ID + ' .cb-bezel{position:relative;background:linear-gradient(160deg,#4c5142,#3a3e31);',
      'border-radius:8px 8px 20px 8px;padding:12px 14px 16px;',
      'box-shadow:inset 0 2px 8px rgba(0,0,0,.65),inset 0 0 0 1px rgba(0,0,0,.4);}',
      '#' + ROOT_ID + ' .cb-screen{display:block;width:100%;height:auto;image-rendering:pixelated;',
      'image-rendering:crisp-edges;background:' + PAL.d3 + ';border:2px solid #1a1e15;border-radius:3px;}',
      // greasy/cracked screen glass overlay (over the LCD, non-interactive, kept faint)
      '#' + ROOT_ID + ' .cb-bezel::after{content:"";position:absolute;left:14px;right:14px;top:12px;bottom:16px;',
      'pointer-events:none;border-radius:3px;',
      'background:radial-gradient(120% 120% at 88% 90%,rgba(0,0,0,.26),transparent 46%),',
      'linear-gradient(48deg,transparent 79%,rgba(15,20,10,.22) 79.4%,transparent 79.8%),',
      'linear-gradient(122deg,transparent 24%,rgba(15,20,10,.15) 24.3%,transparent 24.6%),',
      'radial-gradient(1.4px 1.4px at 31% 61%,rgba(15,20,10,.7),transparent);}',
      // hand-scrawled label taped to the shell
      '#' + ROOT_ID + ' .cb-brand{position:relative;text-align:center;color:#2a2c1e;font-size:9px;',
      'letter-spacing:2px;width:122px;margin:9px auto 3px;padding:3px 0;background:#d7d5c0;',
      'transform:rotate(-1.5deg);box-shadow:0 1px 2px rgba(0,0,0,.4);}',
      '#' + ROOT_ID + ' .cb-brand::before,#' + ROOT_ID + ' .cb-brand::after{content:"";position:absolute;',
      'top:-4px;width:18px;height:9px;background:rgba(222,226,196,.5);box-shadow:0 1px 1px rgba(0,0,0,.25);}',
      '#' + ROOT_ID + ' .cb-brand::before{left:-6px;transform:rotate(-24deg);}',
      '#' + ROOT_ID + ' .cb-brand::after{right:-6px;transform:rotate(20deg);}',

      // CRT scanlines — hidden unless the shell has .cb-crt (LCD FX = on)
      '#' + ROOT_ID + ' .cb-scanlines{display:none;position:absolute;left:14px;right:14px;top:12px;',
      'bottom:16px;pointer-events:none;border-radius:3px;',
      'background:repeating-linear-gradient(to bottom,rgba(0,0,0,.16) 0,rgba(0,0,0,.16) 1px,transparent 1px,transparent 3px);}',
      '#' + ROOT_ID + '.cb-crt .cb-scanlines{display:block;}',
      // reduced-motion: drop the janky tilt
      '#' + ROOT_ID + '.cb-reduced{transform:scale(var(--cb-scale,1));}',   // resize still works, no tilt
      // resize grip (bottom-right of the shell)
      '#' + ROOT_ID + ' .cb-resize{position:absolute;right:2px;bottom:2px;width:15px;height:15px;',
      'cursor:nwse-resize;touch-action:none;opacity:.45;',
      'background:linear-gradient(135deg,transparent 48%,#0d1109 48%,#0d1109 58%,transparent 58%,',
      'transparent 70%,#0d1109 70%,#0d1109 80%,transparent 80%);}',
      '#' + ROOT_ID + ' .cb-resize:hover{opacity:.85;}',

      '#' + ROOT_ID + ' .cb-controls{display:flex;align-items:center;justify-content:space-between;',
      'margin-top:8px;}',

      // D-pad (3x3 grid; N/E/S/W are the live zones)
      '#' + ROOT_ID + ' .cb-dpad{display:grid;grid-template-columns:repeat(3,22px);',
      'grid-template-rows:repeat(3,22px);}',
      '#' + ROOT_ID + ' .cb-dpad button{background:linear-gradient(160deg,#2c2c24,#201f19);border:none;',
      'color:#7c7c6c;font-size:10px;cursor:pointer;touch-action:none;',
      'box-shadow:inset 0 1px 1px rgba(255,255,255,.06),0 1px 2px rgba(0,0,0,.4);}',
      '#' + ROOT_ID + ' .cb-dpad .cb-up{grid-area:1/2;border-radius:4px 4px 0 0;}',
      '#' + ROOT_ID + ' .cb-dpad .cb-left{grid-area:2/1;border-radius:4px 0 0 4px;}',
      '#' + ROOT_ID + ' .cb-dpad .cb-mid{grid-area:2/2;background:#26261f;}',
      '#' + ROOT_ID + ' .cb-dpad .cb-right{grid-area:2/3;border-radius:0 4px 4px 0;}',
      '#' + ROOT_ID + ' .cb-dpad .cb-down{grid-area:3/2;border-radius:0 0 4px 4px;}',
      '#' + ROOT_ID + ' .cb-dpad button:active{background:#3a3a2e;}',

      // A / B
      '#' + ROOT_ID + ' .cb-ab{display:flex;gap:8px;align-items:center;transform:rotate(-18deg);}',
      '#' + ROOT_ID + ' .cb-ab button{width:30px;height:30px;border-radius:50%;border:none;',
      'background:radial-gradient(circle at 38% 32%,#9b3a55,#7a2740 68%,#5f1f33);color:#e9cdd4;',
      'font-weight:bold;font-size:12px;cursor:pointer;touch-action:none;',
      'box-shadow:0 2px 3px rgba(0,0,0,.5),inset 0 -2px 3px rgba(0,0,0,.35),inset 0 1px 1px rgba(255,255,255,.15);}',
      '#' + ROOT_ID + ' .cb-ab button:active{background:#6f2439;transform:translateY(1px);}',

      // Start / Select
      '#' + ROOT_ID + ' .cb-ss{display:flex;gap:14px;justify-content:center;margin-top:10px;}',
      '#' + ROOT_ID + ' .cb-ss button{background:none;border:none;color:#6a6d58;font-size:8px;',
      'letter-spacing:1px;cursor:pointer;touch-action:none;}',
      '#' + ROOT_ID + ' .cb-ss .pill{display:block;width:26px;height:7px;border-radius:4px;',
      'background:#7d7f68;margin:0 auto 3px;transform:rotate(-18deg);}',
      '#' + ROOT_ID + ' .cb-ss button:active .pill{background:#5a5c48;}',

      // Collapsed pill
      '#' + ROOT_ID + '.cb-collapsed .cb-gb{display:none;}',
      '#' + ROOT_ID + '.cb-collapsed .cb-titlebar{border-radius:10px;border-bottom:2px solid #0d1109;}',

      // Debug panel
      '#' + ROOT_ID + ' .cb-debug{margin-top:6px;max-width:196px;background:#0d1109;color:' + PAL.d2 + ';',
      'font-size:9px;line-height:1.4;padding:6px 8px;border-radius:6px;white-space:pre-wrap;',
      'word-break:break-word;}',

      // Header toggle button — sits inline with Travel Table / Computer (unscoped).
      '#contraband-toggle{display:inline-flex;align-items:center;gap:5px;vertical-align:middle;',
      'margin:0 0 0 12px;padding:3px 9px;border:1px solid #0d1109;border-radius:6px;',
      'background:#1f2419;color:' + PAL.d3 + ';font:bold 11px "Courier New",monospace;',
      'letter-spacing:1px;cursor:pointer;line-height:1.4;-webkit-user-select:none;user-select:none;}',
      '#contraband-toggle.cb-tg-float{float:right;margin-top:1px;}', // fallback when no icon row
      '#contraband-toggle:hover{background:#2a3222;}',
      '#contraband-toggle:active{transform:translateY(1px);}',
      '#contraband-toggle .cb-tg-dot{width:7px;height:7px;border-radius:2px;background:#3a3a2e;}',
      '#contraband-toggle[data-on="1"] .cb-tg-dot{background:' + PAL.d3 + ';box-shadow:0 0 5px ' + PAL.d2 + ';}'
    ].join('');
    var s = document.createElement('style');
    s.id = STYLE_ID;
    s.textContent = css;
    (document.head || document.documentElement).appendChild(s);
  }

  // ─────────────────────────────────────────────────────────────── shell ──
  // Builds the Game Boy DOM, wires every control to the input bus, handles
  // collapse + drag. Returns { root, canvas, destroy() }.
  function buildShell(input) {
    injectStyles();

    var root = document.createElement('div');
    root.id = ROOT_ID;
    root.style.setProperty('--cb-scale', '' + (Storage.get('contraband.scale', 1) || 1)); // persisted size
    var collapsed = !!Storage.get('contraband.collapsed', false);
    if (collapsed) root.classList.add('cb-collapsed');

    // Title bar
    var titlebar = document.createElement('div');
    titlebar.className = 'cb-titlebar';
    var title = document.createElement('span');
    title.className = 'cb-title';
    title.textContent = '▸ CONTRABAND';
    var collapseBtn = document.createElement('button');
    collapseBtn.className = 'cb-collapse';
    collapseBtn.type = 'button';
    collapseBtn.textContent = collapsed ? '▢' : '—';
    titlebar.appendChild(title);
    titlebar.appendChild(collapseBtn);

    // Game Boy body
    var gb = document.createElement('div');
    gb.className = 'cb-gb';

    var bezel = document.createElement('div');
    bezel.className = 'cb-bezel';
    var canvas = document.createElement('canvas');
    canvas.className = 'cb-screen';
    canvas.width = 160; canvas.height = 144;
    bezel.appendChild(canvas);
    var scan = document.createElement('div');   // CRT scanline overlay (shown when LCD FX on)
    scan.className = 'cb-scanlines';
    bezel.appendChild(scan);

    var brand = document.createElement('div');
    brand.className = 'cb-brand';
    brand.textContent = 'CONTRABAND';

    var controls = document.createElement('div');
    controls.className = 'cb-controls';

    // D-pad
    var dpad = document.createElement('div');
    dpad.className = 'cb-dpad';
    var dmap = [['cb-up', 'up', '▲'], ['cb-left', 'left', '◀'], ['cb-mid', null, ''],
                ['cb-right', 'right', '▶'], ['cb-down', 'down', '▼']];
    dmap.forEach(function (d) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = d[0];
      b.textContent = d[2];
      if (d[1]) bindHold(b, d[1]);
      dpad.appendChild(b);
    });

    // A / B
    var ab = document.createElement('div');
    ab.className = 'cb-ab';
    ['b', 'a'].forEach(function (act) {
      var b = document.createElement('button');
      b.type = 'button';
      b.textContent = act.toUpperCase();
      bindHold(b, act);
      ab.appendChild(b);
    });

    controls.appendChild(dpad);
    controls.appendChild(ab);

    // Start / Select
    var ss = document.createElement('div');
    ss.className = 'cb-ss';
    [['select', 'SELECT'], ['start', 'START']].forEach(function (p) {
      var b = document.createElement('button');
      b.type = 'button';
      b.innerHTML = '<span class="pill"></span>' + p[1];
      bindHold(b, p[0]);
      ss.appendChild(b);
    });

    gb.appendChild(bezel);
    gb.appendChild(brand);
    gb.appendChild(controls);
    gb.appendChild(ss);

    // resize grip — drag to scale the whole handheld (persisted)
    var resizeGrip = document.createElement('div');
    resizeGrip.className = 'cb-resize';
    resizeGrip.title = 'Drag to resize';
    gb.appendChild(resizeGrip);

    root.appendChild(titlebar);
    root.appendChild(gb);

    (function enableResize() {
      var rz = false, sx = 0, sy = 0, sScale = 1;
      function curScale() { return parseFloat(getComputedStyle(root).getPropertyValue('--cb-scale')) || 1; }
      resizeGrip.addEventListener('pointerdown', function (e) {
        e.preventDefault(); e.stopPropagation();
        rz = true; sx = e.clientX; sy = e.clientY; sScale = curScale();
        try { resizeGrip.setPointerCapture(e.pointerId); } catch (x) {}
      });
      resizeGrip.addEventListener('pointermove', function (e) {
        if (!rz) return;
        var d = ((e.clientX - sx) + (e.clientY - sy)) / 200;   // outward = bigger
        var s = Math.max(0.7, Math.min(2.6, sScale + d));
        root.style.setProperty('--cb-scale', s.toFixed(3));
      });
      function stop() { if (!rz) return; rz = false; Storage.set('contraband.scale', curScale()); }
      resizeGrip.addEventListener('pointerup', stop);
      resizeGrip.addEventListener('pointercancel', stop);
      resizeGrip.addEventListener('touchstart', function (e) { e.preventDefault(); }, { passive: false });
    })();

    // ── control wiring: pointer events cover mouse + touch uniformly ──
    function bindHold(el, action) {
      function onDown(e) {
        e.preventDefault();
        Sound.unlock();   // first real gesture starts the AudioContext
        Haptics.tap();
        input.press(action);
        try { el.setPointerCapture(e.pointerId); } catch (x) {}
      }
      function onUp() { input.release(action); }
      el.addEventListener('pointerdown', onDown);
      el.addEventListener('pointerup', onUp);
      el.addEventListener('pointercancel', onUp);
      el.addEventListener('pointerleave', function () { input.release(action); });
      // Stop iOS long-press callout / scroll.
      el.addEventListener('touchstart', function (e) { e.preventDefault(); }, { passive: false });
      el.addEventListener('contextmenu', function (e) { e.preventDefault(); });
    }

    // ── collapse ──
    function setCollapsed(v) {
      collapsed = v;
      root.classList.toggle('cb-collapsed', v);
      collapseBtn.textContent = v ? '▢' : '—';
      Storage.set('contraband.collapsed', v);
    }
    collapseBtn.addEventListener('click', function (e) {
      e.stopPropagation();
      setCollapsed(!collapsed);
    });
    // Tapping the collapsed pill re-opens.
    titlebar.addEventListener('click', function () { if (collapsed) setCollapsed(false); });

    // ── drag by the title bar (pointer events → touch + mouse) ──
    (function enableDrag() {
      var dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
      titlebar.addEventListener('pointerdown', function (e) {
        if (e.target === collapseBtn) return;
        dragging = true;
        var r = root.getBoundingClientRect();
        ox = r.left; oy = r.top;
        sx = e.clientX; sy = e.clientY;
        root.style.right = 'auto';
        root.style.bottom = 'auto';
        root.style.left = ox + 'px';
        root.style.top = oy + 'px';
        try { titlebar.setPointerCapture(e.pointerId); } catch (x) {}
      });
      titlebar.addEventListener('pointermove', function (e) {
        if (!dragging) return;
        var nx = ox + (e.clientX - sx);
        var ny = oy + (e.clientY - sy);
        var maxX = window.innerWidth - root.offsetWidth;
        var maxY = window.innerHeight - 40;
        root.style.left = Math.max(0, Math.min(maxX, nx)) + 'px';
        root.style.top = Math.max(0, Math.min(maxY, ny)) + 'px';
      });
      function stop() { dragging = false; }
      titlebar.addEventListener('pointerup', stop);
      titlebar.addEventListener('pointercancel', stop);
    })();

    // ── keyboard (desktop) → same bus ──
    var KEYMAP = {
      ArrowUp: 'up', KeyW: 'up',
      ArrowDown: 'down', KeyS: 'down',
      ArrowLeft: 'left', KeyA: 'left',
      ArrowRight: 'right', KeyD: 'right',
      KeyZ: 'a', KeyJ: 'a', Space: 'a', Enter: 'start',
      KeyX: 'b', KeyK: 'b', ShiftRight: 'select'
    };
    function isTyping() {
      var el = document.activeElement;
      if (!el) return false;
      var t = (el.tagName || '').toLowerCase();
      return t === 'input' || t === 'textarea' || el.isContentEditable;
    }
    function onKeyDown(e) {
      var act = KEYMAP[e.code];
      // don't hijack keys when collapsed or hidden via the header toggle
      if (!act || collapsed || root.style.display === 'none' || isTyping()) return;
      e.preventDefault(); // stop arrow/space page scroll while playing
      Sound.unlock();
      input.press(act);
    }
    function onKeyUp(e) {
      var act = KEYMAP[e.code];
      if (!act) return;
      input.release(act);
    }
    window.addEventListener('keydown', onKeyDown, true);
    window.addEventListener('keyup', onKeyUp, true);

    return {
      root: root,
      canvas: canvas,
      setBrand: function (name) { brand.textContent = 'CONTRABAND ▸ ' + name; },
      destroy: function () {
        window.removeEventListener('keydown', onKeyDown, true);
        window.removeEventListener('keyup', onKeyUp, true);
        if (root.parentNode) root.parentNode.removeChild(root);
      }
    };
  }

  // ──────────────────────────────────────────────────────── detection ──
  // Torn's travel page (page.php?sid=travel) shows either the departure/agency
  // UI or the in-flight countdown. We only mount during the in-flight state.
  // Since the exact React markup can shift, detection is heuristic + tunable.

  function isTravelUrl() {
    return /page\.php/i.test(location.pathname) &&
           /(?:^|[?&])sid=travel(?:&|$)/i.test(location.search);
  }

  function getTravelRoot() {
    return document.getElementById('travel-root') ||
           document.querySelector('.content-wrapper.travelling') ||
           document.getElementById('mainContainer') || document.body;
  }

  // In-flight detection uses stable Torn markers confirmed from the live DOM:
  //   • <body data-traveling="true">
  //   • <div class="content-wrapper ... travelling">
  //   • #travel-root flight scene / progress (React hashed-class prefixes)
  // Any one strong signal is enough; hashes in class names can change between
  // Torn builds, so we match by prefix, never the full hashed class.
  function detectInFlight() {
    if (!isTravelUrl()) return { inFlight: false, score: 0, hits: ['not-travel-url'] };
    var hits = [], score = 0;

    if (document.body && document.body.getAttribute('data-traveling') === 'true') {
      score += 2; hits.push('+body-traveling');
    }
    if (document.querySelector('.content-wrapper.travelling')) {
      score += 2; hits.push('+wrapper-travelling');
    }
    if (document.querySelector(
          '#travel-root [class*="airspaceScene"],' +
          ' #travel-root [class*="flightProgressSection"],' +
          ' #travel-root [class*="progressText"]')) {
      score += 2; hits.push('+flight-scene');
    }

    return { inFlight: score >= 2, score: score, hits: hits };
  }

  // ─────────────────────────────────────────────────────────── harness ──
  var mounted = null;         // { shell, engine } when active
  var input = createInput();
  // Release all held buttons when focus/visibility is lost (prevents stuck touch/keys after minimize or app-switch).
  window.addEventListener('blur', function () { input.resetAll(); });
  document.addEventListener('visibilitychange', function () { if (document.hidden) input.resetAll(); });
  var force = 'auto';         // 'auto' | 'on' | 'off'
  var visible = Storage.get('contraband.visible', true) !== false; // header-toggle state
  var detectTimer = 0;
  var urlTimer = 0;
  var observer = null;
  var debugEl = null;
  var lastSignals = null;

  function shouldMount() {
    if (force === 'on') return true;
    if (force === 'off') return false;
    lastSignals = detectInFlight();
    return lastSignals.inFlight;
  }

  // Place the overlay centered on screen (top-left coords, same system as drag).
  function centerOverlay(root) {
    root.style.right = 'auto'; root.style.bottom = 'auto';
    var w = root.offsetWidth || 200, h = root.offsetHeight || 340;
    root.style.left = Math.max(4, (window.innerWidth - w) / 2) + 'px';
    root.style.top = Math.max(4, (window.innerHeight - h) / 2) + 'px';
  }

  // Toggle the CRT/scanline + reduced-motion classes on the shell (called by the
  // engine when LCD FX changes, and on mount).
  function applyFx() {
    if (!mounted) return;
    var root = mounted.shell.root;
    root.classList.toggle('cb-crt', FX.crt && !FX.reduced);
    root.classList.toggle('cb-reduced', FX.reduced);
  }

  function mount() {
    if (mounted) return;
    try {
      var shell = buildShell(input);
      document.body.appendChild(shell.root);
      centerOverlay(shell.root);   // open centered; user can still drag it anywhere
      var engine = createEngine(shell.canvas, input, shell.setBrand, applyFx);
      engine.start();
      mounted = { shell: shell, engine: engine };
      if (debugEl) shell.root.appendChild(debugEl);
      applyFx();
      applyVisibility();
    } catch (err) {
      // Never let a toy error escape into Torn's page. Roll back a partial mount.
      try { if (window.console && console.warn) console.warn('[Contraband] mount failed (contained):', err); } catch (e2) {}
      try { teardown(); } catch (e3) {}
    }
  }

  function teardown() {
    if (!mounted) return;
    mounted.engine.destroy();
    mounted.shell.destroy();
    input.resetAll();
    mounted = null;
    removeToggleButton();
  }

  // ── header toggle button (injected into Torn's travel titleContainer) ──
  function updateToggleButton() {
    var btn = document.getElementById('contraband-toggle');
    if (!btn) return;
    btn.setAttribute('data-on', visible ? '1' : '0');
    btn.title = visible ? 'Hide the in-flight mini game' : 'Show the in-flight mini game';
    btn.innerHTML = '<span class="cb-tg-dot"></span>' + (visible ? 'HIDE GAME' : 'PLAY GAME');
  }
  function ensureToggleButton() {
    injectStyles();
    // Prefer the Travel Table / Computer icon row so the button sits inline with
    // them; fall back to the bare title bar (float right) if that row isn't present.
    var icons = document.querySelector('[class*="titleContainer___"] .tt-top-icons');
    var host = icons || document.querySelector('[class*="titleContainer___"]');
    if (!host) return;
    var btn = document.getElementById('contraband-toggle');
    if (btn) {
      if (btn.parentNode !== host) host.appendChild(btn);   // re-home after a re-render
      btn.classList.toggle('cb-tg-float', !icons);
      updateToggleButton();
      return;
    }
    btn = document.createElement('button');
    btn.id = 'contraband-toggle';
    btn.type = 'button';
    if (!icons) btn.classList.add('cb-tg-float');
    btn.addEventListener('click', function (e) {
      e.preventDefault();
      e.stopPropagation();
      setVisible(!visible);
    });
    host.appendChild(btn);
    updateToggleButton();
  }
  function removeToggleButton() {
    var btn = document.getElementById('contraband-toggle');
    if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
  }
  function applyVisibility() {
    if (mounted) {
      mounted.shell.root.style.display = visible ? '' : 'none';
      if (visible) mounted.engine.resume(); else mounted.engine.pause();
    }
    updateToggleButton();
  }
  function setVisible(v) {
    visible = v;
    Storage.set('contraband.visible', v);
    applyVisibility();
  }

  function evaluate() {
    var want = shouldMount();
    if (want && !mounted) mount();
    else if (!want && mounted) teardown();
    if (want) ensureToggleButton(); // re-inject header button after React re-renders
    renderDebug();
  }

  function startDetector() {
    if (detectTimer) return;
    // Re-evaluate on DOM churn (React re-renders on arrival) + a slow backstop poll.
    observer = new MutationObserver(debounce(evaluate, 250));
    try {
      // attributes so the <body data-traveling> flip is caught immediately on arrival
      observer.observe(document.body, {
        childList: true, subtree: true, characterData: true,
        attributes: true, attributeFilter: ['data-traveling', 'class']
      });
    } catch (e) {}
    detectTimer = window.setInterval(evaluate, 1200);
    evaluate();
  }

  function stopDetector() {
    if (observer) { observer.disconnect(); observer = null; }
    if (detectTimer) { window.clearInterval(detectTimer); detectTimer = 0; }
    teardown();
  }

  function onLocationChanged() {
    if (isTravelUrl()) startDetector();
    else stopDetector();
  }

  function debounce(fn, ms) {
    var t = 0;
    return function () {
      window.clearTimeout(t);
      t = window.setTimeout(fn, ms);
    };
  }

  // ── debug panel ──
  function makeDebugEl() {
    var el = document.createElement('div');
    el.className = 'cb-debug';
    return el;
  }
  function renderDebug() {
    if (!debugEl) return;
    var sig = force === 'auto' ? (lastSignals || detectInFlight()) : { score: '-', hits: [] };
    debugEl.textContent =
      'force: ' + force + '\n' +
      'travelUrl: ' + isTravelUrl() + '\n' +
      'mounted: ' + !!mounted + '\n' +
      'score: ' + sig.score + '\n' +
      'hits: ' + (sig.hits && sig.hits.length ? sig.hits.join(' ') : '(none)');
  }

  // ── public console API (also used by URL hash shortcuts) ──
  window.__CONTRABAND__ = {
    version: '1.5.0',
    force: function (mode) {
      if (['auto', 'on', 'off'].indexOf(mode) === -1) {
        console.warn('[Contraband] force(mode) expects "auto" | "on" | "off"');
        return force;
      }
      force = mode;
      evaluate();
      return force;
    },
    debug: function () {
      if (!debugEl) debugEl = makeDebugEl();
      if (mounted && debugEl.parentNode !== mounted.shell.root) mounted.shell.root.appendChild(debugEl);
      renderDebug();
      console.log('[Contraband] debug panel on. Use __CONTRABAND__.force("on") to force-mount.');
      return this.signals();
    },
    signals: function () {
      var s = detectInFlight();
      console.log('[Contraband] travelUrl=' + isTravelUrl() + ' inFlight=' + s.inFlight +
                  ' score=' + s.score + ' hits=' + s.hits.join(' '));
      return s;
    },
    mount: mount,
    teardown: teardown,
    show: function () { setVisible(true); },
    hide: function () { setVisible(false); },
    toggle: function () { setVisible(!visible); }
  };

  // ── boot ──
  function boot() {
    // Cosmetic prefs + palette.
    FX.palette = parseInt(Storage.get('contraband.palette', 0), 10) || 0;
    FX.crt = Storage.get('contraband.crt', false) === true;
    try { FX.reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
    applyPalette(FX.palette);
    try {
      var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
      var onMq = function () { FX.reduced = mq.matches; applyFx(); };
      if (mq.addEventListener) mq.addEventListener('change', onMq);
      else if (mq.addListener) mq.addListener(onMq);
    } catch (e) {}

    // URL hash shortcuts for quick tuning without the console.
    var h = location.hash || '';
    if (h.indexOf('cb-on') !== -1) force = 'on';
    else if (h.indexOf('cb-off') !== -1) force = 'off';
    if (h.indexOf('cb-debug') !== -1) debugEl = makeDebugEl();

    // Watch SPA navigation (Torn patches history for some transitions) + hash.
    ['pushState', 'replaceState'].forEach(function (m) {
      var orig = history[m];
      if (typeof orig === 'function') {
        history[m] = function () {
          var r = orig.apply(this, arguments);
          window.dispatchEvent(new Event('cb:locationchange'));
          return r;
        };
      }
    });
    window.addEventListener('popstate', onLocationChanged);
    window.addEventListener('hashchange', onLocationChanged);
    window.addEventListener('cb:locationchange', onLocationChanged);
    // Backstop poll in case history isn't patched cleanly.
    urlTimer = window.setInterval(onLocationChanged, 1500);

    onLocationChanged();
  }

  if (document.body) boot();
  else document.addEventListener('DOMContentLoaded', boot);
})();