# OFFLINE.md — running Netrunner Offline fully offline (no PHP)

## Live table update (cards and runs)

The table includes hosted-card inspection, engine-offered installation cancellation,
card drafts with engine labels and limits, and guarded single/numeric choices. The table
shows current ICE strength, subroutine states and access trash costs. Ability confirmation
shows the offered action and card rules before submission. At game over, Play again
restarts and Menu returns to deck selection; the PHP deck editor is omitted in this static view.

Three browser checks drive the CGS table (`table/cgs-view.js`) with real pointer input.
Install `harness` dependencies first (`npm --prefix harness ci`); each output directory
must be new and outside the game folder:

| Check | Covers |
|---|---|
| `node harness/verify-cgs-table.cjs ../cgs-table-check-1` | Layout at 667/844/1366 px, reader and galleries, HUD counters (including the TAG/CORE chips), keyboard order, score areas, dense boards, AI progression. |
| `node harness/check-table-cards.js --out ../table-cards-check-1` | Card decisions: play, host, install cancel, abilities, Tāo swaps, Celebrity Gift, sabotage, traces, discard, long choices, stale-callback guards, hidden-card privacy. |
| `node harness/check-table-run.js --out ../table-run-check-1 --port 8142` | Runs: encounters, breakers, access and trash, steal, jack out, nested prevention, flatline result, AI continuation. |

The table renders each command twice: compact in the dock and in full in the choices dialog.
Options, confirmations and Inspect buttons exist only in that dialog. The two `check-table-*`
scripts therefore click the visible copy and open the dialog with the toolbar when a target
lives there. Full command labels are read from `title`/`aria-label`, because the dock shows a
short face. Cards are inspected through the card reader and the More → Servers / Opponent
hand galleries. `check-table-run.js` pins René Arcemont for the trash-access scenario, so it does
not depend on the menu's default Runner deck. See `table/README.md` for Chrome selection and
evidence limits.


Human vs AI launches `offline.html?view=table` with the selected human seat and decks.
[Using the live table](table/README.md) describes its controls and current limits. The
**Legacy view** link starts a new match in the original renderer; it does not migrate the
current match. Tutorials and gauntlet URLs retain that renderer. AI vs AI uses the new
table, with pause/resume, viewing-seat selection and playback speed controls.

The service worker now precaches `table/bridge.js`, `table/view.js`, all four table styles
(`reference.css`, `live.css`, `interaction.css`, `player-areas.css`),
`table/catalog.json` and the three scene/illustration images. The distribution copy list
includes `table/`. Card art still uses the existing runtime cache. Missing card art has a
fallback and cannot hold the Pixi startup queue indefinitely. Deck information and legacy settings
keep their close controls inside a small landscape viewport. These presentation changes supersede the
older statements below that describe `style.css` as having no geometry changes.

A fresh local public-build check now verifies an activated worker/controller and named cache entries, disables page and worker networking, reloads the menu and launches a normal human table. Required table assets return from the worker cache. Unvisited artwork can receive a neutral placeholder; this does not cover the entire collection. The earlier missing-registration result was reproduced with a long Windows test-profile path: Chrome rejected `CacheStorage.open` and removed the failed worker. Short temporary profiles activated the same build. See `table/README.md` for the commands and limits.

Build locally without downloads or deployment with `node tools/build-local.mjs ../table-public`, then serve that exact output with `node harness/serve.js 8123 ../table-public`. The output must be a new directory outside the product.

This is a local live-table build. Complete collection offline coverage, physical touchscreen
behavior, deployment and durable saved matches remain outside the observed evidence.


This fork of chiriboga (NEU-DrBo6, pinned c863f5a) — rebranded **Netrunner Offline** in
the player-facing surfaces (menu page, page titles, chrome) while all upstream credit
stays in the menu footer — ships with a static entry page and a
Node headless harness so the game runs, is batch-driven and is AI-tuned entirely from
local files. PHP is not needed anywhere.

## 1. What was added (nothing existing was rewritten)

| Path | Purpose |
|---|---|
| `menu.html` | Static launcher menu (the site root `/`), presented as a game front-end (run025): a hero with one dominant **Quick Game vs AI** button and Human vs AI / AI vs AI / Tutorials as secondary paths, then the Custom game panels (deck pickers, format filter), Tutorials, a secondary Play offline panel, the Browse all decks list and a credits footer. Styled with the live table's neon tokens and bundled fonts (Exo 2 for all UI text, hierarchy by weight; Orbitron only for the logo wordmark - run027); custom-styled selects, toggles and buttons with hover, pressed, focus-visible and disabled states; decorative motion is off under `prefers-reduced-motion`. Generated — do not hand-edit. |
| `tools/make-menu-page.mjs` | Regenerates `menu.html` from the `precons/` directory (sides AND factions derived from `carddata/carddata.json` + `sets/coreset.js`, tutorial URLs extracted from `index.php`; HTML entities in deck names, e.g. `&#039;`, are decoded for display). |
| `offline.html` | Static equivalent of `engine.php` (same script include order, PHP blocks pre-rendered, Google Analytics removed, `<title>` rebranded to "Netrunner Offline", bundled-font `@font-face` injected). Generated — do not hand-edit. |
| `tools/make-offline-page.mjs` | Regenerates `offline.html` from `engine.php` (fails loudly if `engine.php` changes shape). |
| `style.css` | Game chrome, rethemed in run004 from the old Matrix-green CRT to the cyberpunk palette: color VALUES only — selectors, structure, spacing and geometry are untouched (git diff auditable). |
| `fonts/` | Bundled OFL fonts: `Exo2-Variable.ttf` (Exo 2 variable, weights 100-900 - the one UI family of the menu and the CGS table, run027), `Orbitron-Variable.ttf` (logo wordmark only) and `ShareTechMono-Regular.ttf` (kept for the legacy renderer's `style.css`), each with its `SIL Open Font License 1.1` text (`*-OFL.txt`). Loaded from relative `fonts/` URLs only — no CDN, works offline. |
| `harness/serve.js` | Minimal static file server, binds `127.0.0.1` only (no PHP). Serves `/` and `/index.html` from `menu.html`; `offline.html` stays directly addressable. |
| `harness/lib/inproc.js` | In-process engine runner (run 003): loads the byte-identical engine scripts into a Node `vm` context with shimmed browser globals and plays complete AI-vs-AI games with no browser, no server and no display (section 4.5). |
| `harness/run-game.js` | Runs ONE AI-vs-AI ("faceoff") game headless, writes a JSON report. Accepts `--engine inproc\|chrome` (section 4.5). |
| `harness/batch.js` | Runs N games across >=2 deck pairs with parallel workers + summary. Accepts `--engine inproc\|chrome` (section 4.5). |
| `harness/tuner.js` | Runs named AI parameter configurations x N games, emits a ranked aggregate. One distinct per-game result file per game regardless of `--workers`. Accepts `--engine inproc\|chrome` (section 4.5). |
| `harness/compare.js` | A/B evaluation (run006): N games per arm per pair, interleaved, baseline (search OFF) vs variant (`AIOverrides.runner.runSearch` ON), in-process only; per-arm win rates with 95% Wilson CIs, the delta with Newcombe + Wald 95% CIs, median game length, RunCortex decision counters and sampled differing-decision traces (section 4.6). Run 007 additions: the TRUE Newcombe method-10 diff CI (conservative interval kept as `newcombeConservativeDiff95`; `--selftest` pins the math), a `learned` arm (`--config-file`, default `harness/learned-config.json`), per-decision JSONL logging (`--log-decisions`, section 4.7) and process sharding (`--shard i/K` + `--merge-shards`). |
| `harness/lib/decisionlog.js` | Per-decision JSONL logger for in-proc games (run007 L1): wraps `RunCortex.decide` + `MakeRun` from the harness side (zero engine edits), feature vector + candidates + chosen + backfilled game outcome per line; OFF by default, enabled with `--log-decisions <file>` on run-game/batch/compare (section 4.7). |
| `harness/validate-decisions.js` | Schema validator for decision-logger JSONL: per-line type/range/outcome checks + coverage stats; exit 0 iff 0 malformed lines (run007 L1). |
| `harness/verify-learned-config.js` | Run007 E2 check: loads `harness/learned-config.json` into a live in-proc game via the same merge path the compare.js `learned` arm uses and proves (config readback from the vm context) that the artifact plays with exactly the learned values (section 4.7). |
| `tools/learn-weights.mjs` | Self-play learner (run007 L2, pure Node): (1+lambda) evolution strategy over the 9-dim RunCortex config (7 weights + k + vetoesBelow), fitness = runner win rate of N in-proc games vs stock corp; emits `harness/learned-config.json` + a learning-curve JSON; adaptive eval-size ladder + wall-time budget guard (section 4.7). |
| `harness/verify-menu.js` | Headless end-to-end verification of `menu.html` (clicks the real menu elements, inspects seat AI assignment, plays an AI-vs-AI game to a win, and checks the AI-vs-AI game-speed backup/restore). |
| `harness/capture-views.js` | Headless Chrome rendered captures of the menu and game views (run004 visual-review coverage matrix) into an output directory plus a `manifest.json` (viewports, states, fonts loaded, external-request guard, documented non-capturable rows). See section 3.3. |
| `harness/configs.json` | Named AI parameter configurations (consumed via `window.AIOverrides`). |
| `harness/decks/pairs.json` | Named corp/runner deck pairs for batch/tuner runs. |
| `harness/decks/nrdb-*.json` | Deck files imported from NetrunnerDB. |
| `harness/decks/fixtures/` | Test-only deck fixtures (e.g. an intentionally `usable: false` deck used to prove the harness refuses it). |
| `tools/import-nrdb-deck/index.mjs` | NRDB decklist importer (emits `precons/nrdb-*.js` + harness deck JSON + coverage report). |
| `sw.js` | Service worker (run009 P1, NEW): versioned precache of the app shell + all engine set files, cache-first runtime caching for card images (gray placeholder on offline miss) and other same-origin GETs; registered from both generated pages (generator-emitted block). See section 3.5. |
| `harness/verify-pwa.js` | Headless PWA offline verification (run009 P1): normal menu warmup -> activated controller and named cache entries -> page and worker offline -> menu reload and normal table launch, with required asset response/byte checks (section 3.5). |
| `carddata/vantagepoint.json`, `carddata/liberation.json` | Run009 C1 catalogs (NEW files): Vantage Point (66 cards) and the Liberation cycle (tai + rwr, 130 cards), normalized to the carddata.json record shape with NRDB sources + fetch date (section 3.5). |
| `sets/vantagepoint.js` | Run009 C3 tranche implementations (NEW file): the 19 Vantage Point cards + Elevation's Embedded Reporting played by the six target tournament decks, under real NRDB codes with basic AI hints (section 3.5). |
| `harness/smoke-tranche-run009.js` | Per-card smoke driver for the run009 tranche (NEW): boots the real engine in-process, fires each card's hooks through the engine's own decision plumbing, asserts state changes (section 3.5). |
| `harness/verify-format-data-run009.mjs` | Independent format-data re-check for the merged run009 catalog view (NEW; run005's `tools/verify-format-data.mjs` stays untouched) (section 3.5). |
| `precons/nrdb-*.js` | Imported registerPrecon deck files (deck_set "NRDB Import"). |
| `ai_corp.js`, `ai_runner.js` | Surgical additions only (see section 6): a `window.AIOverrides` merge hook in each AI constructor plus three named tunables, plus one guarded 17-line call site into the optional run search (run006). No algorithm changes. |
| `ai_runcortex.js` | RunCortex (run006, NEW file): optional Monte Carlo run-decision search for the Runner AI. Inert unless enabled via `AIOverrides.runner.runSearch` (default OFF) — see section 4.6. |
| `vercel.json`, `tools/vercel-build.sh` | Static deployment: fetches images at build time and assembles the `public/` output directory (section 10). |
| `images/` | Untracked local asset (see section 5). Not part of the repo contents. |

## 2. Prerequisites

- Node.js >= 18 (tested with v22) and npm
- Chrome/Chromium at `C:\Program Files\Google\Chrome\Application\chrome.exe`
  (override with the `CHROME_PATH` environment variable)
- Install harness dependencies once:

```
npm --prefix harness install
```

## 3. Serve the game statically

From the repo root:

```
node harness/serve.js 8123
```

This serves the repo root at `http://127.0.0.1:8123` (localhost only). Open
`http://127.0.0.1:8123/` in a browser to get the launcher menu (section 3.1) and pick a
game. The game page itself is directly addressable at
`http://127.0.0.1:8123/offline.html`. (`/index.html` also serves the menu; if
`menu.html` has not been generated yet, the root falls back to `offline.html`.)

Python alternative (equivalent): `python -m http.server 8123 --bind 127.0.0.1`

### 3.1 The launcher menu (menu.html)

`menu.html` is generated from the `precons/` directory:

```
node tools/make-menu-page.mjs
```

It lists every `precons/*.js` deck (including the 13 NRDB imports) with its side, identity
and flags, and offers:

- **Format filter (run005)** — an All / Standard / Startup segmented control
  (`#format-filter`, labelled Deck format) in the Custom game header above the game panels. It filters the Human-vs-AI and AI-vs-AI deck
  selects, the Quick Game pool and the Browse all decks grid to decks legal in the selected
  format (default All = legal in at least one format). Decks illegal in BOTH formats
  ("casual only", e.g. the Gateway starters with their ban-list cards) are excluded from
  every launch pool in every filter state; they remain visible in the Browse all decks grid with
  a struck-through name and a Casual only badge, with reasons in
  `tools/format-report.md`. See section 3.4.
- **Quick Game (run006)** — you vs the AI with random decks from the Quick Game pool,
  stock rules (same launch URL shape as the upstream `index.php` launcher). It is the
  hero's primary button; the line under it shows the current pool sizes (Corp/Runner). The draw pool is the
  run006 eligibility map (`tools/format-data.json`, key `quickGame.eligibleDeckNamesByState`)
  intersected at menu-generation time with format legality: 2 corp + 4 runner decks, all
  legal in BOTH Standard and Startup, so the pool is non-empty in every filter state
  (All/Standard/Startup) and only legal decks are ever drawn. (The upstream
  `useForQuickGame` flag is no longer used for drawing — all 17 decks carrying it are
  casual-only under the run005 format data, which left the pool empty in every state;
  the generator fails loudly if the map ever names a deck that does not exist or is
  illegal in a state it is listed for.) If a future map left a state genuinely empty,
  the launch is refused with an inline message instead of picking an illegal deck.
- **Human vs AI** — pick your side and deck; the AI takes the other seat. The opponent
  deck is auto-selected (the opposite side's official starter) unless you pick one. Runs
  at your current game speed (engine default 350) and stock agenda points. The two deck
  slots are side-coded: your seat carries a corp steel-blue bar when you play corp and the
  opponent slot flips to the runner hot red (and vice versa).
- **AI vs AI (spectate)** — pick corp + runner decks and an agenda-point target (default
  7). The corp seat is steel-blue coded, the runner seat hot-red coded. Before
  navigating, the menu temporarily sets a readable playback delay through the engine's
  own setting: it backs up your current `chiriboga-settings.gameSpeed` to a dedicated
  `nroff-speed-backup` localStorage key, stores 1800 ms and navigates to the new table.
  The table offers pause/resume, viewing-seat selection and 1×/2×/4× playback.
  Every time the menu loads again it restores
  the backed-up value — or deletes the key entirely if you had never set a speed — so
  your own human/quick/tutorial games run at the speed you had (engine default 350),
  while the spectated game uses the selected playback speed.
- **Tutorials** — the two interactive starter-deck tutorials extracted from upstream
  `index.php` (their `engine.php?...` URLs are rewritten to `offline.html?...`).

Deck `<select>`s group the options by faction (`<optgroup>` in canon order: corp
Haas-Bioroid → Jinteki → NBN → Weyland → Neutral; runner Anarch → Criminal → Shaper →
Neutral, name-sorted inside each group). The Browse all decks panel at the bottom expands into
a grid with a faction chip legend and per-deck faction dot + CORP/RUNNER side tag, identity,
deck set and Quick / Starter tags; casual-only decks stay in the grid (struck through,
Casual only badge) but never in the launch selects.

### 3.2 Visual theme and bundled fonts (run004)

The player-facing chrome is rethemed from the old Matrix-green CRT to a cyberpunk
direction (Blade Runner / Cyberpunk 2077 neon-on-dark):

- Base background near-black blue `#0a0e14`; panels `#10161f`/`#141b26`; cool white text
  `#d8e6f0`, dim `#8fa3b8`.
- Electric cyan `#00e5ff` is the primary accent (interactive highlights, glows, scanline
  tint). Scanline/glow effects are kept but retinted and slightly softened. The
  background win watermark ("CORP WINS!" / "RUNNER WINS!", `--watermark-color` /
  `--watermark-opacity` on `.netrunner-bg-watermark`; red under `body.run-active`) was
  raised from opacity 0.26 to 0.40 (run004 r3 ride-along) so it reads clearly while
  staying a background watermark; no other watermark value changed.
- Side coding matches the card backs: corp steel blue `#5b8dff`, runner hot red
  `#ff2e63` (in the game chrome the same hot red replaces the old damage red).
- Cyberpunk yellow `#ffd60a` is used sparingly (warnings, tutorial edges, the primary
  CTA edge).
- Faction chips per card-frame canon: anarch `#ff6b35`, criminal `#ffc300`, shaper
  `#7ee081`, haas-bioroid `#4da3ff`, jinteki `#e63946`, nbn `#ffb703`,
  weyland-consortium `#a08a6a`, neutral `#9aa5b1`.
- `style.css` was mapped mechanically (green → cyan, red → hot red, dark green panels →
  blue-blacks). CSS variable NAMES (`--crt-green`, `--bg-dark`, ...) are deliberately
  KEPT with new values, because `engine.php` (whose inline styles pass through verbatim
  into the generated `offline.html`), `index.php`, `decklauncher.php` and `gauntlet.php`
  reference those exact names and are outside the retheme scope. No layout, spacing or
  geometry rule changed.
- Zero-green scope (run004 r2): the retheme is not CSS-only — rendered green can also
  come from engine-JS color literals and from CSS filter chains that tint white icon
  SVGs. All three layers are covered:
  - `style.css`: values plus the SVG icon-tint filter rules — the green-producing
    `hue-rotate(55deg)`/`hue-rotate(67deg)` icon filters (menubar deck-info/rulebook/
    debug, fullscreen button, footer + lightbox icons) were re-aimed at the primary
    cyan (e.g. `hue-rotate(140deg)` renders ≈ `rgb(4,121,139)`–`rgb(40,250,255)`), and
    the green portrait/high-score photo tints to a cool steel cast.
  - `init.js` / `utility.js`: COLOR-LITERAL-ONLY swaps in inline-style strings and in
    the run-state counter theming (`UpdateCounterColors`: counter text fill/stroke and
    the Pixi sprite tint that colored the HUD book/credit/click/MU/agenda icons) —
    `#33ff33`→`#00e5ff`, `#66ff66`→`#7df0ff`, `#ccffcc`→`#d8e6f0`,
    `rgba(51,255,51,a)`→`rgba(0,229,255,a)`, `#ffff66`→`#ffd60a`, dark-green stroke
    → dark cyan, green tint `0x147014`→steel `0x5b8dff`; the active-run red coding is
    preserved. 22 changed lines total, every one an auditable color swap; no logic,
    structure or behavioral changes. The white icon/counter PNG/SVG assets
    (`images/hand_size.png`, `credit.png`, `click.png`, `rulebook.svg`,
    `fullscreen.svg`, NISEI icons) were verified and left untouched.
  - the deck-info modal container's inline green in the generated `offline.html` is
    neutralized from `style.css` (`#help-content { color: #00e5ff !important; }`),
    keeping the generated page's transform scope (title/font-face) untouched.
  - the five history-rail sprites (run004 r3, contract v3 extension): `images/history_corp.png`,
    `history_runner.png`, `history_corp_small.png`, `history_runner_small.png`,
    `history_runner_run.png` carried Matrix-green `rgb(96,230,26)` connector stubs (plus
    the softer anti-aliased sibling `rgb(113,194,116)` in the corp icon). They were
    selectively recolored pixel-by-pixel to the cyan family (green-dominant pixels
    `g > r+40 and g > b+40` hue-shifted to 187° `#00e5ff` with each pixel's original
    saturation/value kept; alpha and every non-green pixel — the red active-run coding,
    white icons, blue circle bodies — byte-identical). The untouched originals are
    preserved as `images/history/<name>.orig.png` (that directory is deliberately NOT
    added to `tools/vercel-build.sh`'s copy list — `images/` already ships wholesale, so
    no build-script edit was made). Recolor tool + per-file proof:
    `.gauntlet/run004-tools/recolor-history-r3.py` and
    `.gauntlet/evidence/run004/round3/sprite-recolor-audit.json` (245 green pixels mapped
    to cyan across the five files; 0 after; red set and alpha byte-identical; no green
    halo at 3x zoom). The same five files' cyan-stub rendering is covered by the extended
    pixel audit below.

- **Deployment plumbing:** the five recolored sprites are also tracked in `theme-assets/` (`images/` is gitignored; the upstream `images.zip` ships the original green sprites), and `tools/vercel-build.sh` copies them over `images/` after every image download — the deployed site gets the themed rail. Originals: `images/history/*.orig.png` (local only).

Bundled fonts (all offline, relative `fonts/` URLs, **no CDN**):

| File | Face | License |
|---|---|---|
| `fonts/Exo2-Variable.ttf` | Exo 2 variable, weights 100-900 (run027: the one UI family of the menu and the CGS table; hierarchy by weight - body 400, numbers 500, labels 600, headings 700) | SIL OFL 1.1 (`fonts/Exo2-OFL.txt`) |
| `fonts/ShareTechMono-Regular.ttf` | Share Tech Mono (terminal text of the legacy renderer, `style.css`) | SIL OFL 1.1 (`fonts/ShareTechMono-OFL.txt`) |
| `fonts/Orbitron-Variable.ttf` | Orbitron variable (the "NETRUNNER OFFLINE" wordmark only) | SIL OFL 1.1 (`fonts/Orbitron-OFL.txt`) |
| `cardrenderer/Play-Bold-Nisei.ttf` | Play Bold Nisei (card rendering, upstream) | shipped upstream, unchanged |

`style.css` and both generated pages declare the `@font-face` rules, so the legacy chrome gets
the Share Tech Mono face it always referenced but never shipped; both generated pages also
declare Exo 2 (`font-weight: 100 900`, `font-display: block`) for the menu and the CGS table. Note on native
`<select>` popups: their dropdown surfaces are OS-rendered, so faction colors inside the
open dropdown are best-effort (Chromium honors the `option[data-faction]` colors used
here) and cannot be captured headlessly — the reliable faction signals are the
`<optgroup>` grouping, the chip legend and the Browse all decks grid.

### 3.3 Rendered captures (run004 visual review)

`harness/capture-views.js` implements the visual-review coverage matrix with headless
Chrome (it spawns its own `serve.js` on 127.0.0.1:8127 unless `--server` is given; the
AI-vs-AI game is launched by driving the real menu elements, ap 6 + game speed 50, the
Gateway starter pair, and captured early/mid/at the win overlay):

```
node harness/capture-views.js --out ../.gauntlet/evidence/run004/round3
```

(run from the repo's `chiriboga/` directory, so the output lands in the run's
`.gauntlet/evidence/` tree; `--note "<text>"` can be repeated to record context in the
manifest's `notes`.)

The game-mid row is condition-driven, not a fixed delay: after "Game begins" the console
feed is polled until at least two run-related log lines appear ("Encountering",
"Approaching", "Run successful", "Run ends", rez/spent markers), capped at 90 s, and
never after a win line. What triggered the capture is recorded in the manifest's
`gameMid` entry. If the game ends before the trigger fires, the harness launches ONE
more game (same decks, ap 6, speed 50) to obtain a genuine mid frame; if that also
fails, `game-mid.png` is skipped and the reason recorded instead of shipping a
misleading frame.

Outputs: `menu-1600.png`, `menu-1280.png`, `menu-alldecks-open.png`, `menu-hover.png`,
`menu-hover-link.png`, `menu-selects-styled.png` (close-up of the side-coded Human-vs-AI
panel), `title-offline.png`, `game-early.png`, `game-mid.png`, `game-log-normal.png`
(log crop taken during the mid-game state), `game-win.png`,
`game-log-detail.png` (fullest log crop) and `manifest.json` (every capture's viewport
+ state, `document.fonts.check` results for the bundled fonts, page titles, ALL request
hosts, the game-mid trigger info, and the documented NON-CAPTURABLE row: open native
select popups are OS-rendered and cannot be screenshotted headlessly). The manifest's
`externalRequests` list must be empty — any request to a host other than the capture
server is recorded there.

Pixel-level green proof (run004 r3): `.gauntlet/run004-tools/pixel-green-audit-r3.py`
extends the round-2 HUD-region audit with the history-rail region — the right-edge
vertical strip `x=1540..1600, y=60..1200` where `#history-wrapper` (`top:65px; right:0;
width:56px`) renders the 50x50 run-log sprites — and asserts zero saturated green
(`g>200, r<100, b<100`), zero dark-green tint and zero pixels of the original sprite
green shades across every region of `game-early/mid/win/title`:

```
python .gauntlet/run004-tools/pixel-green-audit-r3.py .gauntlet/evidence/run004/round3
```

(run from the repo root; the same directory that was passed to `capture-views.js`).
Green-dominant pixels remaining inside the rail are counted informationally only — they
belong to the upstream card-art thumbnails inside the run-log circles, which are out of
retheme scope (contract non_goals). The r2 script remains at
`.gauntlet/probe/pixel-green-audit-r2.py`.

The generated launch URLs use the same deck parameter format as the harness
(`deckToUrlParam` in `harness/lib/common.js`): `{identity, cards[]}` LZ-compressed with
the game's own `deck/lz-string.min.js`. Like the upstream `index.php` launcher, the
menu's payload additionally carries `name`/`url`/`notes` for the engine's deck-info
popup (`decks.js` consumes `identity`/`cards`; the extra fields feed `ShowDeckInfo`).
The page has stable element ids (`#quick-game`,
`#hva-side-corp`, `#hva-side-runner`, `#hva-deck`, `#hva-opponent`, `#launch-hva`,
`#aia-corp`, `#aia-runner`, `#aia-ap`, `#launch-aia`, `#tutorial-1`, `#tutorial-2`) so it
can be driven headless:

```
node harness/verify-menu.js --server http://127.0.0.1:8123 --ap 6 --timeout-min 25 --out harness/results/verify-menu.json
```

`verify-menu.js` loads the menu, clicks through Quick Game, Human vs AI on both sides and
both tutorials (checking per seat that the human side has AI `null` and the AI side an AI
instance, and that the game begins), then launches an AI-vs-AI game with the decks given
via `--aia-corp`/`--aia-runner`. Since run005 the launch selects only contain decks legal
in at least one format, so the historical defaults (the Gateway starter pair
`precons/Gateway Corp.js` + `precons/Gateway Runner.js`) and the run002 duel-deck
override are no longer selectable: both carry ban-list cards and are casual-only (see
3.4). The AIA want-lists therefore fall back to the first legal pair
(`precons/Pecularity.js` + `precons/Bowel Movements.js`, a matchup the engine AI
reliably finishes) and waits for a win. The AI-vs-AI launch is also checked
for the game speed 50 acceleration, and a dedicated speed-leak flow proves that
acceleration does not leak into human games: after an AI-vs-AI launch (both with no
stored speed and with a custom speed seeded beforehand) the menu must restore the
previous speed, so the following Human-vs-AI game loads with `mainLoopDelay` 350 (or the
custom 1000) and no stray 50 in `chiriboga-settings`, with the `nroff-speed-backup` key
cleared. It fails on any external request. Exit code 0 iff every flow passes.

Run005 added the format-filter flow (last in the suite): it asserts the default All
state, the Standard and Startup filter states (deck options in all four selects exactly
match the embedded classification, casual-only rows hidden, standard-illegal decks
unreachable under Standard, Seamless Launch decks hidden under Startup), an AI-vs-AI
launch under the Standard filter with a legal pair, the removal of the Gateway starters
from every launch pool in every state, and the return to All.

### 3.4 Deck formats & ban lists (run005)

Deck content is classified against the two supported tournament formats, encoded as
data in `tools/format-data.json` (schema `netoffline-format-data/1`) and consumed by
`harness/lib/formats.js` (`classifyDeck`). Classification feeds three places: the menu
data embedded by `tools/make-menu-page.mjs`, the stored report, and the importer.

- **Standard** — post-Seventh Rotation pool: System Gateway, Elevation, Vantage Point,
  the Ashes cycle, the Borealis cycle (Downfall, The Automata Initiative, Rebellion
  Without Rehearsal) and the Liberation cycle. The engine implements SG + Elevation
  fully-enough for deck work plus parts of Downfall/TAI/RWR; the implemented pool codes
  are recorded in the data file (`implementedPoolCodes`). Ban list: 26.08, unchanged by
  the 26-09 balance update (published 2026-09-04, effective 2026-09-19) — 14 runner +
  19 corp entries (33 listed names resolve to 32 distinct cards: "Hoshiko Shiro: Untold"
  and "Protagonist" are one card, 26066 "Hoshiko Shiro: Untold Protagonist").
  Sources: https://nullsignal.games (rotation policy) +
  https://nullsignal.games/blog/standard-balance-update-26-09/.
- **Startup** — pool after the 2026-03-02 rotation: System Gateway + Elevation +
  Vantage Point (implemented codes: sg, elev). Ban list post-26-09: corp
  [Let Them Dream, Offworld Office, Seamless Launch, Mercia B4LL4RD], runner [Cleaver];
  NBN: Reality Plus unbanned. Agenda rule: a Startup corp deck may contain at most **4
  agenda card copies** with a printed agenda point value >= 3 (checked at classification
  time only). Source: https://nullsignal.games/blog/startup-balance-update-26-09/
  (2026-09-21), full-list history from the 25.11/26.04 pages.

Every ban-list name is mapped to its NRDB code via `carddata/carddata.json` (two names
have no code in the local export — Méliès U and Let Them Dream belong to unimplemented
sets — and are recorded honestly as unresolved rather than guessed), and each banned
code records whether the engine actually implements it (raw `sets/*.js` scan; e.g.
Cleaver 30006, Offworld Office 30067, Seamless Launch 30040, Mercia B4LL4RD 35045,
Bukhgalter 26016, Luminal Transubstantiation 30036, Touch-ups 35067, K2CP Turbine 33090,
Rezeki 26026 are implemented-and-banned).

Regenerate the stored classification after any precon or ban-list change:

```
node tools/classify-precons.mjs
```

This rewrites `tools/format-report.md` (human-readable per-deck statuses + removal list)
and `.gauntlet/evidence/run005/classification-report.json` (machine-readable). Current
state: 84 decks — 14 legal in BOTH formats, 70 casual-only. Two independent verifiers
guard the data and the classifier (both must pass):

```
node tools/verify-format-data.mjs --out ../.gauntlet/evidence/run005/E1-format-data-crosscheck.json
node tools/verify-classification.mjs
```

`verify-format-data.mjs` re-derives the ban-list mappings, implemented flags, pool pack
codes, config.js idRange agreement, sources and agenda rule from raw
carddata/sets/config parses (contract research_facts hardcoded verbatim;
`harness/lib/formats.js` NOT used). `verify-classification.mjs` recomputes every deck's
legality from a regex precon parse + raw carddata and requires 100% agreement with the
stored report.

**Removal policy** — a deck legal in NEITHER format is excluded from every launch pool
(Quick Game, Human vs AI, AI vs AI) in EVERY filter state, including All; it remains in
the Browse all decks grid struck-through with a Casual only badge and its precon file is kept
(history/upstream sync; the harness can still run it explicitly by file). This removes
70 decks from the pools, including the shipped NSG Tutorial starters (Gateway Corp:
Offworld Office x3 + Seamless Launch x2; Gateway Runner: Cleaver x2) and the 8 SG+SU21
duel decks — full list with reasons in `tools/format-report.md`.

**NRDB refresh (run005)** — five new format-legal imports (4 runner + 1 corp, all legal
in BOTH formats; "Core Battle Box" in some titles is the NRDB event name — every card
was verified to come from System Gateway/Elevation, no Core Set cards):

| Deck | File | Side |
|---|---|---|
| Core Topan | `precons/nrdb-core-topan.js` | runner |
| Elevation/core Topan | `precons/nrdb-elevationcore-topan.js` | runner |
| MuslihaT - Core Battle Box | `precons/nrdb-muslihat-core-battle-box.js` | runner |
| Topan - Core Battle Box | `precons/nrdb-topan-core-battle-box.js` | runner |
| PT Untaian Shell Game/Assets - Core Battle Box | `precons/nrdb-pt-untaian-shell-gameassets-core-battle-box.js` | corp |

Each completed at least one harness game (proofs: `.gauntlet/evidence/run005/E4-game-corp-*.json`).
The honest scan story (212 deck pages + 895 API-classified decklists + 840 seeded-search
candidates across 4 runs; 1105 rejections, essentially all "unimplemented-cards" because
the implemented pool predates most current-era decks):
`.gauntlet/evidence/run005/E4-import-report-consolidated.json`.

### 3.5 Card pool expansion + PWA offline (run009)

Run009 expands the implemented pool with the two currently Standard-legal card groups
the engine was missing, data-driven, and fixes the "installed PWA does not launch
offline" production incident.

**PWA offline (P1).** `sw.js` (site root, NEW) is a service worker with a versioned
cache (the run009 candidate used the earlier `nroff-vision3` family; the current build uses `nroff-complete-offline-v15`, with the exact revision in `sw.js`): it precaches the app shell (both generated pages, `style.css`, the
top-level engine scripts, every `sets/*.js` derived from `config.js` setRegistry at
install time, `deck/lz-string.min.js`, `deck/seedrandom.min.js`, jquery, the
cardrenderer scripts, bundled fonts (`fonts/Exo2-Variable.ttf`, `fonts/ShareTechMono-Regular.ttf`, `fonts/Orbitron-Variable.ttf`; the OFL texts are shipped but not precached), manifest) and runtime-caches card images
(`images/`) and everything else same-origin cache-first as encountered; offline misses
on card images answer with a tiny gray placeholder so the game keeps rendering.
`activate` deletes older caches (bump `CACHE` to force a refresh). Registration is a
fixed deterministic `<script>` block emitted by BOTH page generators
(`tools/make-menu-page.mjs`, `tools/make-offline-page.mjs`), so the generated pages
stay byte-idempotent. `manifest.json` is polished (name "Netrunner Offline", run-004
palette colors, root scope). `tools/vercel-build.sh` copies `sw.js` into `public/` and
asserts it. The current verifier warms a normal table, waits for actual worker control and named cache entries, then disables both page and worker networking. It reloads the menu, starts a new normal table and checks required asset responses. Use output paths outside the product:

```
node harness/serve.js 8199              # terminal 1 (any port; 127.0.0.1 is a secure context)
node harness/verify-pwa.js --server http://127.0.0.1:8199 --out ../pwa.json --shot ../pwa.png
```

**Catalog (C1).** `carddata/vantagepoint.json` (66 cards, pack `vp`, codes 36001-36066)
and `carddata/liberation.json` (130 cards, packs `tai` + `rwr`, codes 34001-34130) were
fetched from NetrunnerDB (v3 printings API + server-rendered set pages, 2026-10-13;
sources inside the files) and normalized to the `carddata.json` record shape, resolving
run005's "pack code unverified" caveats.

**Prioritization (C2).** 100 recent Seventh-Rotation-era NRDB tournament decklists were
scanned (`harness/lib/formats.js` legality against the merged catalogs): 6 format-legal
decks found, needing only **20 unimplemented cards in union** (far under the ~80-card
checkpoint). Ranked plan: `.gauntlet/evidence/run009/E2-prioritization.json` (raw deck
pages under `.gauntlet/evidence/run009/raw/c2/`).

**Implementation (C3).** The full 20-card tranche is implemented in `sets/vantagepoint.js`
(19 Vantage Point cards + Elevation's Embedded Reporting 35059) under their real NRDB
codes, registered in `config.js` (2-line setRegistry entry, `vp` = 36000-36999, hidden
+ untested like the other tranche sets). Basic `//**AI code` hints throughout
(`AIRunEventExtraPotential`, `AIImplementIce` maps, `AIPreferredInstallChoice`, ...).
Per-card smokes (in-proc engine boot, hooks fired through the engine's own
response/decision plumbing):

```
node harness/smoke-tranche-run009.js --out smoke.json     # 20/20 cards PASS
```

The smokes caught and fixed several real bugs in the new implementations before they
could ship (wrong shuffle function name, `Rez` callback-argument misuse, subroutine
`this` binding, a `Reveal(..., null, ...)` crash, an install/rez ordering bug, an
over-strict trojan install hint). `harness/verify-menu.js` stays green after the menu
regeneration.

**Format data + imports (C4).** `tools/format-data.json` gains `vp` in the Standard and
Startup pools (with the pack-code reference entry and implemented counts), resolves the
Liberation cycle to `tai` + `rwr`, records the run009 ban check (none of the 20 tranche
cards is banned), and resolves run005's two UNRESOLVED ban entries from the new catalog
(Méliès U = 36036, Let Them Dream = 36066). `harness/lib/formats.js` merges the two new
catalogs into its card lookup (base entries win), so classification decides 34xxx/36xxx
packs directly; `harness/verify-format-data-run009.mjs` is the independent re-check
against the merged view (58 checks PASS; the run005 tool `tools/verify-format-data.mjs`
is kept untouched and now reports only the two "vp not in carddata.json" checks it
cannot see past — the merged catalogs are separate files by contract). The 6 target
decks were imported with the standard importer (all USABLE, Standard+Startup legal):

| Deck | File | Side |
|---|---|---|
| Il y aura du sang (3rd in Worlds '26 Startup) | `precons/nrdb-il-y-aura-du-sang-3rd-in-worlds-and-039-26-startup.js` | corp (NBN: Reality Plus) |
| Just Here for the Clean Getaway Zipline (3rd in Startup) | `precons/nrdb-just-here-for-the-clean-getaway-zipline-3rd-in-startup.js` | runner (MuslihaT) |
| Bag Mags (2026 Worlds Startup 1st Place) | `precons/nrdb-bag-mags-2026-worlds-startup-1st-place.js` | runner (Magdalene Keino-Chemutai) |
| [Startup] De Profundis (3-0 Worlds 2026) | `precons/nrdb-startup-de-profundis-3-0-worlds-2026.js` | corp (Weyland: Built to Last) |
| Credit Vampire 1-2 @World Startup 2026-10-04 | `precons/nrdb-credit-vampire-1-2-world-startup-2026-10-04.js` | runner (Zahya Sadeghi) |
| Rich and fast and deadly 3-0 @World Startup 2026-10-04 | `precons/nrdb-rich-and-fast-and-deadly-3-0-world-startup-2026-10-04.js` | corp (Weyland: Built to Last) |

**Playable + docs (C5).** Each imported deck completed an in-proc AIA game (ap 6, zero
fatal errors): `.gauntlet/evidence/run009/E5-game{1,2,3}-*.json` (three games pair the
six decks). Honest AI note: the new cards carry only basic hints (preferred options and
ice maps); they are NOT tuned, and the AI does not play them expertly.

Known gaps (honest): **Myōshu (36061)** approximates "Play only if you scored an agenda
this turn that you did not install this turn" as "at least one agenda in the score
area" (the engine has no per-turn score tracker to reuse; documented in the card file).
40 of 66 Vantage Point cards and 125 of 130 Liberation-cycle cards remain unimplemented
(outside the frequency tranche); decks containing them import as unusable and are
excluded from the launch pools, as designed.

### 3.6 Second expansion tranche: fresh scan, Startup wave + Liberation tranche (run010)

Run010 repeats the run009 loop with Vantage Point implemented: a fresh NRDB
tournament scan (X1), imports of every newly format-legal deck, and the next
implementation tranche chosen by the contract's selection rule (X2).

**Fresh scan (X1).** `harness/scan-run010.mjs` scanned the NRDB public listings with
VP implemented (recent decklists first, then tournament listings; 100 decklist pages
fetched/parsed, 0 parse errors, 265 candidates listed). Result: **4 newly format-legal
decks** (2 Standard+Startup, 2 Startup-only) - the expected post-VP Startup wave plus a
Standard runner deck. Scan report: `.gauntlet/evidence/run010/E1-scan-report.json`
(raw deck pages under `.gauntlet/evidence/run010/raw/scan/`). All four were selected
as targets (X2): their missing-card union is **11 cards**, far below the ~80-card
checkpoint, and no target deck needs an Ashes card (no Ashes checkpoint). Ranked plan:
`.gauntlet/evidence/run010/E2-prioritization.json`. The frequency scan showed the
actual unlock path is Vantage Point + Elevation cards - zero unimplemented Liberation
cycle (tai/rwr) cards appear in any format-legal deck - so the contract's "Liberation
tranche" is implemented under those real NRDB codes.

**Implementation (X2).** `sets/liberation.js` (NEW) implements the 11-card union:
Nebula Talent Management: Making Stars 35057 (NBN flip identity), Next Big Thing 35060,
Bigger Picture 35065, IP Enforcement 35066, Beta Build 36019, Méliès City Luxury
Line 36026, Ansel 2.0 36028, Caveat Emptor 36032, The Red Room 36045, Grubber 36050,
Lethe 36051. Registered in `config.js` (3-line setRegistry entry, cycle label `lib`
34001-34130, hidden + untested like the other tranche sets - classification never tests
this label; pack membership comes from the carddata catalogs, where these cards are
`elev`/`vp`). Basic `//**AI code` hints on the cards the target decks attack with
(`AIRunEventExtraPotential` on Beta Build; `AIImplementIce` maps on Ansel 2.0, Grubber,
Lethe). Per-card smokes (in-proc engine boot, hooks fired through the engine's own
response/decision plumbing):

```
node harness/smoke-tranche-run010.js --out ../smoke-run010.json     # 11/11 cards PASS
```

The smokes caught one real bug before it could ship (Beta Build's run-end hook crashed
on a second fire after the engine machinery had already consumed it during the pumped
run; now guarded, matching its Enumerate). Honest approximation (documented in the card
file): the engine has no double-faced identity rendering, so Nebula Talent Management's
flip is an internal flag implementing both sides' abilities; the card face shown stays
the base side. Next Big Thing (35060): the engine's ability enumeration only exposes the
Corp's own score area, so the "usable even if this agenda is in the Runner's score area"
half is implemented for the scored copy only - a stolen copy keeps its counter but its
click ability is not usable (documented in the card file).

**Format data + ban check (X3).** `tools/format-data.json` required NO change: the
tranche cards' real NRDB pack codes are `elev` (4) and `vp` (7), already in both
format pools, and the Liberation cycle packs (`tai` + `rwr`) were already in the
Standard `implementedPoolCodes`. Ban check on all 11 new cards: **none is banned** in
either format (Standard resolved bans 26016/26026/30006/30036/30067/33090/35067;
Startup 30006/30040/30067/35045). Recorded check:
`.gauntlet/evidence/run010/E3-format-check.json`.

**Imports (X1).** All four target decks imported with the standard importer - every
card line implemented, all USABLE:

| Deck | File | Side | Formats |
|---|---|---|---|
| first runner deck no clue how good it is :aga: | `precons/nrdb-first-runner-deck-no-clue-how-good-it-is-aga.js` | runner (Magdalene Keino-Chemutai: Cryptarchitect) | Standard + Startup |
| NBN | `precons/nrdb-nbn.js` | corp (NBN: Reality Plus) | Standard + Startup |
| Totes Undefeated Leo (2026 Worlds Startup 1st Place) | `precons/nrdb-totes-undefeated-leo-2026-worlds-startup-1st-place.js` | corp (LEO Construction: Labor Solutions) | Startup |
| [Startup] Star Power (post-Offworld ban) | `precons/nrdb-startup-star-power-post-offworld-ban.js` | corp (Nebula Talent Management: Making Stars) | Startup |

Classification re-run (94 decks; delta vs run009: +4 total, +2 Standard-legal,
+2 Startup-legal - both new Standard decks are legal in BOTH formats):

```
node tools/classify-precons.mjs --out ../classification-run010.json --md tools/format-report.md
```

Regression (X3): full verify-menu suite green after the menu regeneration
(`tools/make-menu-page.mjs`, 94 decks embedded), and an 8-game in-proc batch in which
every pair includes a newly imported deck:

```
node harness/serve.js 8123 &                                # terminal 1
node harness/verify-menu.js --server http://127.0.0.1:8123 --out ../verify-menu-run010.json
node harness/batch-run010.mjs --games-per-pair 2 --out-dir ../batch-run010   # 8/8, 0 fatal
```

**Playable + docs.** Per-deck AIA game proofs (in-proc, ap 6, zero fatal errors), each
of the four decks in at least one game:

```
node harness/proof-run010.mjs --out-dir ../proofs-run010     # 3/3 proofs PASS
```

Evidence: `.gauntlet/evidence/run010/E1-game{1,2,3}-*.json` (proofs),
`E3-card-smokes.json` (11/11), `E3-verify-menu.json`, `E3-format-check.json`,
`E4-classification-report.json` + `E4-classification-delta.json`, raw batch/proof
outputs under `.gauntlet/evidence/run010/raw/`.

Known gaps (honest): the AI-hint coverage is basic (4 hook sites across the 11 cards;
the decks' game proofs show the AI completes games with them, not that it plays them
expertly). 40 of 66 Vantage Point cards, 1 of 82 Elevation cards (35058 Synapse Global:
Faster than Thought) and 125 of 130 Liberation-cycle cards remain unimplemented (outside
the frequency tranche); decks containing them import as unusable and are excluded from
the launch pools, as designed.

## 4. Headless harness

All commands run verbatim from the repo root (chiriboga/).

### 4.1 One game (C1/C2)

```
node harness/run-game.js --pair gateway-starters --ap 6 --out harness/results/my-game.json
```

- `--pair` selects a corp/runner deck pair from `harness/decks/pairs.json`.
  Alternatively pass decks directly: `--corp "precon:Gateway Corp" --runner "precon:Gateway Runner"`
  (any `precons/<name>.js` or `harness/decks/<file>.json` deck works).
- `--ap 6` shortens the agenda target to 6 (default for the harness because full 7-point
  AI-vs-AI games regularly exceed 20 minutes; use `--ap 7` for stock rules).
- `--timeout-min 20` is the wall-clock cap (default 20).
- `--speed 50` sets the engine's `mainLoopDelay` (ms per action) via the engine's own
  localStorage `gameSpeed` setting. 50 is the engine's documented AI-testing pace
  (decks.js); 350 is the shipped menu default; `--speed 0` leaves the engine default.

The output JSON contains: `outcome` (`CORP_WINS` | `RUNNER_WINS` |
`NO_WIN_WITHIN_TIMEOUT` | `FATAL_ERROR`), `winnerSide`, `elapsedSeconds`, `deckPair`,
`url`, `externalHosts[]` (MUST be empty offline), `fatalErrors[]` (page errors),
`failedRequests[]` and the last console `lastLogLines[]`. Exit code is 0 iff the game
ended in a win.

### 4.2 Batch (C3)

```
node harness/batch.js --games 12 --pairs gateway-starters,duel-e2 --workers 2 --ap 6 --out-dir harness/results/batch-001
```

- Runs 12 games alternating the two pairs, 2 parallel headless Chrome workers.
- Batches default to `--ap 6` for tractable per-game durations (documented deviation
  from stock 7-point games; make it explicit in commands).
- Writes `game-XXX-<pair>.json` per game plus `summary.json` containing `games`,
  `completions`, `completionRate`, and a `perPair` breakdown (games, completions,
  completion rate, corp/runner wins, median duration).

### 4.3 Tuning (C5)

```
node harness/tuner.js --configs baseline,jumpy-corp --games-per-config 5 --pair gateway-starters --ap 6 --out-dir harness/results/tune-001
```

- Runs each named configuration from `harness/configs.json` over N games of the same
  pair and writes `tuning-summary.json` with a `ranked` array (per-config games, wins,
  win rate, corp/runner wins, median duration), best first.
- Each game also writes its own raw result `tune-<config>-<NN>.json`, where NN is that
  game's index within the config. Labels are derived from the task index, so they are
  collision-free under any `--workers` count and map one-to-one to the `gamesIndex`
  entries in `tuning-summary.json`.
- Configurations are injected as `window.AIOverrides` BEFORE any page script runs;
  the hooks in the AI constructors merge them onto the AI instances (section 6).
- `bold-runner` and `passive-corp` are additional ready-made configurations.

### 4.4 Import NetrunnerDB decks (C4)

```
node tools/import-nrdb-deck/index.mjs https://netrunnerdb.com/en/decklist/84374 https://netrunnerdb.com/en/decklist/84375 --report harness/results/import-report.json
```

For each NRDB decklist URL the importer fetches the public page, parses the embedded
deck data (`identity_code` + `cards[{card_code,qty}]`), maps every NRDB card code onto
the implemented engine pool and emits:

1. `precons/nrdb-<slug>.js` — a `registerPrecon` file (`useForCustomGame: true`, all
   other flags false, `deck_set: "NRDB Import"`, notes listing implemented/missing counts).
2. `harness/decks/nrdb-<slug>.json` — a deck file for `run-game.js`/`pairs.json`.
3. A coverage report line per deck:
   `deck "<name>" | side <corp|runner> | implemented X/Y card lines | USABLE|UNUSABLE | missing: ...`

Decks with ANY missing cards are still emitted but marked `usable: false` and the
harness refuses to run them (legality is never faked). The refusal applies on EVERY
load path — deck JSON files and `precon:` refs alike (`preconToDeck()` carries the
flag through) — and fails with a nonzero exit and a message naming the deck, e.g.:

```
node harness/run-game.js --corp decks/fixtures/unusable-deck.json --runner precon:Gateway Runner
# RUN_GAME_ERROR: deck "FIXTURE unusable deck (test-only, do not play)" is marked
# unusable (missing cards: 30059). Only fully-implemented decks may be played ...
```

Run005 added format awareness (same classifier as the menu, `harness/lib/formats.js`):
every fully-implemented deck is classified, `--legal` only emits decks legal in Standard
or Startup, and `--scan` crawls NetrunnerDB public listings (recent pages, tournament
lists, the `api/2/day` day-walk and seeded title searches; caps via `--pages`,
`--max-decks`, `--max-days`, `--target`) importing only implemented + format-legal
decks, writing every rejection with its reason into the scan report. Imported decks get
a `deck_set` naming their format (`NRDB Import Standard+Startup` when legal in both).
The run005 scan story and its 5 imports are documented in section 3.4; the
consolidated report is `.gauntlet/evidence/run005/E4-import-report-consolidated.json`.

`harness/decks/fixtures/unusable-deck.json` is a test-only fixture (a copy of a fully
implemented import deliberately marked `usable: false`) that exists to exercise this
refusal. Card-code mapping: NSG-era sets
are keyed by NRDB codes in `sets/*.js`; Core Set cards are custom-renumbered 1000-1999
in `sets/coreset.js` where each entry's `imageFile` ("01051.png") preserves the original
NRDB code and provides the mapping. `carddata/carddata.json` is used only to name
missing cards in the report. Local `*.html` file paths are accepted instead of URLs for
fixture re-runs. `--dump-pool` prints the parsed engine pool for inspection.

To play an imported pair, add it to `harness/decks/pairs.json` (the four SG+SU21 duel
episodes are already registered as `duel-e1`..`duel-e4`) or pass the deck files directly:

```
node harness/run-game.js --corp decks/nrdb-sgsu21-duel-episode-2-near-earth-hub-vs-zahya.json --runner decks/nrdb-sgsu21-duel-episode-2-zahya-sadeghi-vs-neh.json --ap 6 --out harness/results/my-import-game.json
```

### 4.5 In-process engine (`--engine inproc`)

All three harness runners can execute the engine inside the Node process itself —
no Chrome, no static server, no display, no puppeteer in the code path:

```
node harness/run-game.js --engine inproc --pair gateway-starters --ap 6 --out harness/results/inproc-single.json
node harness/batch.js --engine inproc --games 12 --pairs gateway-starters,duel-e2 --workers 2 --ap 6 --out-dir harness/results/batch-inproc
node harness/tuner.js --engine inproc --configs baseline,jumpy-corp --games-per-config 5 --pair gateway-starters --ap 6 --out-dir harness/results/tune-inproc
```

- `--engine chrome|inproc` selects the execution engine; the default is `chrome`, so
  every command documented before this section is unchanged. In-process defaults:
  `--timeout-min 10` (chrome: 20/15) and `--speed 0` (chrome: 50).
- `harness/lib/inproc.js` loads the byte-identical engine scripts (`deck/lz-string.min.js`,
  `deck/seedrandom.min.js`, `init.js`, `phase.js`, `command.js`, `checks.js`, `mechanics.js`,
  `utility.js`, `config.js`, every set in `setRegistry` plus `gauntlet` and `tutorial`
  (exactly the sets `offline.html` document.writes), `decks.js`, `runcalculator.js`,
  `ai_corp.js`, `ai_runner.js`) into a Node `vm` context in the same relative order
  `offline.html` uses, with the browser environment shimmed:
  - `$`/jQuery: chainable stub (`prop("checked")` false, `is()` false, getters return
    `""`), plus a real deep-copy `jQuery.extend` — load-bearing, since the engine uses
    it to clone card definitions (`InstanceCard`) and phase templates
    (`CreatePhaseFromTemplate`); the shim's check is cross-realm safe.
  - `document`/elements, `window`, `location` (`search` = the synthetic game query, so
    `URIParameter()` works), `localStorage` backed by a Map and pre-seeded with
    `chiriboga-settings` `{"gameSpeed":0}` (or the `--speed` value), a captured
    `console`, `navigator`, and absorb-everything fallbacks for unexpected DOM calls.
  - Fast timers: with `--speed 0` every `setTimeout` collapses to `setImmediate`
    (with per-callback error capture, so an engine exception becomes a `PAGEERROR`
    in `fatalErrors` exactly like Chrome's `pageerror` event instead of killing the
    harness process); `--speed N` with N > 0 honours real delays (honest pacing,
    verified at `--speed 50`).
  - Renderer stub: `CardRenderer.Renderer`/`Cascade` and `PIXI.loaders.Loader` are
    stubbed (`LoadTexture`/`CreateCard`/`CreateCounter` return dummies); the real
    `cardrenderer.js`, `pixi*.js` and `particlesystems.js` are not loaded.
- Speed mechanism: the seeded `gameSpeed` goes through the engine's OWN
  `LoadGameSpeedFromSettings` (init.js), so `mainLoopDelay` is 0 without touching any
  engine file; at `mainLoopDelay < 1` the engine's `Render()` is a built-in no-op
  ("console only if rapidplay required"). The game is then driven exactly like the
  page: `Init()` (the `<body onload>` hook) → the loader's `Setup()` callback →
  `StartGame()`.
- Win detection and result JSON match Chrome mode: the engine logs
  "Game begins"/"Corp wins"/"Runner wins" through `Log()` → `console.log`, the runner
  captures that feed and applies the same win patterns as the Chrome runner. Results
  carry `engine: "chrome" | "inproc"` (the field is new in both modes) and, for
  in-process, `mainLoopDelay` (the value actually in effect after boot, for
  verification). `url` is the synthetic game query
  (`inproc://offline.html?faceoff=1&p=c&ap=...&c=...&r=...`) — schema-compatible;
  `externalHosts` is always `[]` (no network is possible) and `failedRequests` `[]`.
- Deck resolution, the `usable: false` refusal and `window.AIOverrides` injection
  work identically in both engines.
- No engine file was modified for this: the typeof-guard budget (20 guarded lines)
  was not needed (0 lines used).

Equivalence (run 003 evidence, identical pairs, `--ap 6`, 12 games per engine,
2 workers each):

| metric | chrome (speed 50) | inproc (speed 0) |
|---|---|---|
| completions | 12/12 (100%) | 12/12 (100%) |
| corp : runner wins, gateway-starters | 1 : 5 | 3 : 3 |
| corp : runner wins, duel-e2 | 5 : 1 | 2 : 4 |
| median totalLogLines (min-max) | 1262 (775-2098) | 1129 (644-1390) |
| median elapsedSeconds (min-max) | 136 (82-259) | 13 (2-27) |

Both engines complete all games, both sides win on both pairs in both engines, and
game length (same console feed → `totalLogLines`) is the same order of magnitude.
Observed differences, honestly stated: per-pair corp/runner split varies between the
engines (6 games per pair is a small sample and each game is a fresh random shuffle);
elapsed wall time is not comparable (rendering + 50 ms pacing vs no rendering + no
pacing). Raw comparison: `.gauntlet/evidence/run003/E3-comparison.md`.

Timing (P5): ten sequential single-worker in-process games on gateway-starters at
`--ap 6` had a pooled median of 5.5 s over two sequential runs (20 games); rerun
steady-state median ~4 s, first-run median 11 s, range 1-171 s (11 finished in <= 7 s).
The slow tail is the longest games (~1400+ log lines), whose runner AI
run calculations dominate; wall time also includes ordinary machine noise. Batch
medians are higher under parallel workers because all in-process games share one Node
process (12-game batch at `--workers 2`: median 13 s). Raw distributions:
`.gauntlet/evidence/run003/E5-timing-sequential/` and `E5-timing-rerun/`.

When to use which engine: in-process mode is for automation — batches, tuning and
future search work where games must run in seconds; Chrome mode remains the reference
for anything visual or interactive (watching a game, rendering, menu flows) and is the
ground truth the in-process runner was validated against.

### 4.6 Runner run-decision search ("RunCortex") and A/B evaluation (run006)

`ai_runcortex.js` (new file, no existing file was rewritten for it) is an optional
Monte Carlo search over the Runner's run/no-run decision. At the action-phase point
where the stock Runner AI has built and sorted its candidate run targets
(`serverList`), the cortex re-scores every candidate — plus the implicit "no run"
option — and may narrow the list or veto the run entirely:

- For each candidate it runs `k` sampled rollouts (default 24, round-robin under a
  100 ms wall budget) of the run subgame with the engine's OWN `RunCalculator`
  (`runcalculator.js`, the same `IceAI` unknown-ice modeling and path search the stock
  AI uses). The sampled quantity is the corp's assumed credit count (0..real credits):
  sampling it is a Monte Carlo over unknown-ice identities and corp rez decisions.
- A rollout's value: `+3.0 x` the AI's own server potential `- 1.5 x damage - 2.0 x
  tags - 0.25 x credits spent - 0.5 x clicks - 0.8 x misc_moderate`, and a flat `-4.0`
  when no complete path exists; all weights plus `k`, candidate count and the veto
  threshold are configurable (`window.RunCortex.config`, restorable via
  `RunCortex.defaults()`).
- The best candidate's mean EV must beat `vetoesBelow` (default 0, the value of not
  running) or the run is vetoed. Every decision is appended to
  `window.RunCortex.decisionLog` with per-candidate EVs and whether it differed from
  the stock heuristic's choice — that trace is the S3 evidence.

**Activation — default OFF.** The module is inert until
`window.AIOverrides.runner.runSearch` is set before `Init()`; with the flag off, a
guard short-circuits and the stock AI is behavior-identical (verified: a no-override
game logs zero cortex decisions — `.gauntlet/evidence/run006/E2-default-off-proof.json`).
The hook in `ai_runner.js` is one guarded 17-line call site (contract budget: 30).

Smoke — one AIA game with the search ON completes (in-process, seconds):

```
node harness/run-game.js --engine inproc --corp "precon:Pecularity" --runner "precon:Bowel Movements" --ap 6 --ai-overrides "{\"runner\":{\"runSearch\":true}}" --name "RunCortex smoke ON (pair1)" --out ../.gauntlet/evidence/run006/E2-smoke-runsearch-inproc.json
```

A/B evaluation (`harness/compare.js`, in-process only) — 2 arms x 2 format-legal deck
pairs x 50 interleaved games, runner win rates with 95% Wilson CIs, the delta with
Newcombe and Wald 95% CIs:

```
node harness/compare.js --arms baseline,runsearch --games 50 --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 6 --out-dir ../.gauntlet/evidence/run006/compare-main
```

Run006 results (200 games, all completed or honestly reported as timeouts at the 600 s
cap — 6 baseline vs 3 runsearch, zero fatal errors; raw per-game JSONs +
`summary.json` in `.gauntlet/evidence/run006/compare-main/`):

| metric | baseline (search OFF) | runsearch (ON) |
|---|---|---|
| runner wins / games | 43 / 100 | 53 / 100 |
| runner win rate (Wilson 95% CI) | 43% [33.7%, 52.8%] | 53% [43.3%, 62.5%] |
| per-pair runner win rate (pair1 / pair2) | 36% / 50% | 46% / 60% |
| median game length (s / log lines) | 22 / 974 | 18 / 919 |

Delta (variant - baseline): **+10.0 pp, Newcombe 95% CI [-9.5, +28.8] pp (Wald
[-3.8, +23.8])** — the confidence interval includes zero.

**Honest verdict (contract S3 rule): `null-result`.** The rule for a
recommended-default is >= +5 pp AND a 95% CI excluding zero; the point estimate
clears the bar on both pairs individually (+10 pp each), but with 100 games/arm the
data cannot rule out zero (or a small negative effect). The search also did not
observeably hurt: no cortex errors in any game, and search-arm games ended sooner
(median 18 s vs 22 s). Where the search actually differed from the stock heuristic:
323 of 1014 decisions (31.9%) chose differently from `serverList[0]`-or-run,
including 201 vetoes (19.8% of decisions) — sampled traces are preserved in
`summary.json` (`cortexDecisionExamples`), e.g.:
- `runsearch-pair1-001`, Runner 1.3, 12 credits vs corp 7: stock would run Remote 2
  (potential 0.98), the search vetoed (both candidates' mean EV <= 0 after sampled
  damage/cost).
- `runsearch-pair2-001` (a runner WIN), Runner 1.3, 1 credit vs corp 6: stock would
  run R&D (assumed cost 5.8, sampled path damage 4, EV -3.3), the search switched to
  HQ (cost 0.6, EV +2.6).

RunCortex therefore ships OFF (the run006 human checkpoint: user decides whether to
iterate the approach — e.g. richer rollout state or a larger k — or drop the line).

### 4.7 Learning pipeline (run 007): decision logger, self-play learner, honest evaluation

Run 007 resolved the run006 checkpoint by iterating the search line via LEARNING —
three pieces, all harness-side (zero engine-file edits):

1. **Decision logger** (`harness/lib/decisionlog.js`, L1). Opt-in per-decision JSONL
   logging for in-proc games. Enabled per harness command with
   `--log-decisions <file>` (supported by `run-game.js`, `batch.js` and
   `compare.js`; add `--no-log-stock-runs` for cortex-only lines). **Off by
   default** — without the flag nothing is written and no wrapper is installed.
   It wraps two call sites from the harness side (no engine edit): every
   `RunCortex.decide` search decision (`source: "cortex"`, with the candidate EV
   table) and every actual run initiation (`source: "stock"`, via `MakeRun`).
   One JSON object per line; the game outcome is backfilled into every line at
   game end. Schema (full field-by-field doc in the `decisionlog.js` header):

   ```
   { game, ts, side:"runner", source:"cortex"|"stock",
     features: { clicks, credits, creditsAvailable, handCount, installedCount,
                 breakerCount, muUsed, muTotal, tags, brainDamage,
                 agendaPointsRunner, agendaPointsCorp, serverIceCount,
                 serverIceStrengthSum, serverName, corpCredits, corpHandCount,
                 stackCount, discardCount, phase },
     candidates: [ { server, potential, meanEV, samples, completeFraction,
                     serverIceCount, serverIceStrengthSum } ],  // cortex only
     chosen,                       // server name or "no-run" (cortex veto)
     stockWould, differedFromDefault, kRequested, elapsedMs,     // cortex only
     gameOutcome: { outcome, winnerSide, agendaDiff } }          // backfilled
   ```

   `harness/validate-decisions.js <file.jsonl | dir>` validates every line
   (types, ranges, outcome/winner consistency) and prints coverage stats; exit
   code 0 iff 0 malformed lines. Logged evidence: 24 games (12 per pair,
   runsearch arm, ap 6) -> 497 lines, **0 malformed** (301 cortex + 196 stock,
   20.7 lines/game; 105 of the 301 cortex decisions differed from the stock
   heuristic):

   ```
   node harness/compare.js --arms runsearch --games 12 --label run007-logging --log-decisions ../.gauntlet/evidence/run007/decisions/decisions.jsonl --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 8 --out-dir ../.gauntlet/evidence/run007/decisions
   node harness/validate-decisions.js ../.gauntlet/evidence/run007/decisions/decisions.jsonl --report ../.gauntlet/evidence/run007/decisions/validation-report.json
   ```

2. **Self-play learner** (`tools/learn-weights.mjs`, L2, pure Node, no new deps).
   A (1+lambda) evolution strategy — hill-climbing with Gaussian mutants — over
   the 9-dim RunCortex value vector: the 7 weights (`agendaPoint, damage, tag,
   credit, click, miscModerate, etr`) plus `k` and `vetoesBelow` (log-normal
   weight mutation, sigma 25%; `k` sigma 6; `vetoesBelow` sigma 0.5;
   `maxCandidates`/`timeBudgetMs`/`logSize` stay FIXED — they are compute knobs).
   Fitness of a candidate = runner win rate of N in-proc games (candidate config
   merged into `window.RunCortex.config` per game, search ON) vs the STOCK corp
   AI over a fixed interleaved rotation of the two legal pairs. The reference
   (default config) fitness is measured FIRST with the same protocol, twice
   (A/B) — the A-B gap is the honest noise floor of the fitness function.
   Acceptance is strict: a mutant must beat the incumbent by MORE than one game
   at its N (`winRate > best + 1/N`), so pure noise ties keep the incumbent.
   Wall-time discipline: the eval size N shrinks adaptively down a ladder
   (`--games` -> 16 -> 12 -> 8) when the projected wall time threatens
   `--budget-min`; the ACTUAL N of every eval is recorded in the curve. The
   artifact `harness/learned-config.json` (`{config, fitnessHistory,
   referenceFitness, meta}`) is written ONLY if >= `--min-gens` (default 3)
   generations COMPLETED; the full curve is ALWAYS written
   (`.gauntlet/evidence/run007/learn/learning-curve.json`), crash-safe after
   every generation.

   Run 007 learner history, reported honestly:
   - Attempt 1 (N=24, lambda=2, proc=10, budget 40 min) was killed by external
     cancellation at wall ~51 min during generation 11; 10 generations had
     completed. Preserved untouched as
     `.gauntlet/evidence/run007/learn/attempt1-cancelled/` (curve + stderr). Its
     only accepted mutant (gen8, 0.625 vs reference 0.5833) was a one-game win
     at the float-tie boundary of the acceptance rule and did NOT become the
     artifact (the process died before writing).
   - Attempt 2 (canonical; same tool, N=16, lambda=2, proc=12, budget 40 min):
     10/10 generations, 352 games, wall 39.6 min, exit 0. Reference fitness
     0.5625 (A) / 0.5000 (B) -> noise floor 6.25 pp at N=16. **No mutant was
     accepted in any generation**: the best-of-generation win rates were 0.4375,
     0.625, 0.4375, 0.4375, 0.5, 0.4375, 0.5, 0.625, 0.375, 0.625 — three
     mutants hit 0.625 = reference + exactly 1/16 and were (correctly) rejected
     as measurement-resolution ties. The artifact therefore contains the DEFAULT
     config (k=24, vetoesBelow=0, the seven default weights): the honest learning
     outcome is "the optimizer found nothing better than the default config at
     its measurement resolution".

   ```
   node tools/learn-weights.mjs --gens 10 --games 16 --lambda 2 --proc 12 --budget-min 40 --min-gens 3
   node harness/verify-learned-config.js --config-file harness/learned-config.json --out ../.gauntlet/evidence/run007/learn/verify-learned-config.json
   ```

   `harness/verify-learned-config.js` is the E2 acceptance check: it runs one
   game with the default config and one with the artifact config merged via the
   SAME onContext code path the compare.js `learned` arm uses, then reads the
   config back from the live vm context. PASS requires: learned-game readback ==
   artifact, default-game readback == engine defaults, learned game produced
   cortex decisions. Run 007: PASS=true. Because the learned config IS the
   default config, a second run with a deliberately non-default config file
   (`.gauntlet/evidence/run007/learn/mechanism-check-config.json`, k=8,
   vetoesBelow=-1, etr weight 2.0) proves the load+merge path changes actual
   search behavior: the learned game's decisionLog shows `kRequested: 8` vs the
   default game's 24 (`.gauntlet/evidence/run007/learn/verify-mechanism-check.json`,
   PASS=true).

3. **Evaluation** (`harness/compare.js`, L3). The run006 tool gained the
   `learned` arm (loads `--config-file`, default `harness/learned-config.json`,
   field `.config` deep-merged into `window.RunCortex.config` per game),
   `--log-decisions` passthrough, process sharding (`--shard <i>/<K>` writes
   `summary-shard-<i>.json`; `--merge-shards <dir>` rebuilds the pooled summary)
   and the TRUE Newcombe method-10 difference CI. Run006 reported the
   CONSERVATIVE Wilson-endpoint-subtraction interval; that is still computed and
   reported as `newcombeConservativeDiff95` for continuity, but the primary
   `newcombeMethod10Diff95` is now the real method-10 square-and-add interval
   (lower = d - sqrt((p1-l1)^2 + (u2-p2)^2), upper = d + sqrt((u1-p1)^2 +
   (p2-l2)^2), with (l1,u1)/(l2,u2) the arms' Wilson intervals) — pinned by a
   hand-computed worked example in the source and a self-test:

   ```
   node harness/compare.js --selftest
   ```

   Final evaluation (400 games; 2 arms x 2 pairs x 100 games; interleaved
   round-robin; 4 shard processes x 4 workers; ap 6; per-game cap 6 min, recorded
   honestly as NO_WIN_WITHIN_TIMEOUT in BOTH arms; zero fatal errors; raw
   per-game JSONs + `summary.json` in `.gauntlet/evidence/run007/eval/`):

   ```
   node harness/compare.js --arms baseline,learned --games 100 --label run007-eval --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 4 --timeout-min 6 --shard 0/4 --out-dir ../.gauntlet/evidence/run007/eval
   node harness/compare.js --arms baseline,learned --games 100 --label run007-eval --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 4 --timeout-min 6 --shard 1/4 --out-dir ../.gauntlet/evidence/run007/eval
   node harness/compare.js --arms baseline,learned --games 100 --label run007-eval --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 4 --timeout-min 6 --shard 2/4 --out-dir ../.gauntlet/evidence/run007/eval
   node harness/compare.js --arms baseline,learned --games 100 --label run007-eval --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box" --engine inproc --ap 6 --workers 4 --timeout-min 6 --shard 3/4 --out-dir ../.gauntlet/evidence/run007/eval
   node harness/compare.js --merge-shards ../.gauntlet/evidence/run007/eval
   ```

   (The four shard commands are the actual evaluation run — their captured
   stderr logs are `.gauntlet/evidence/run007/eval-shard{0..3}-stderr.log`; the
   merge command is re-executable any time and rebuilds `summary.json` from the
   400 raw per-game JSONs.)

   | metric | baseline (search OFF) | learned (search ON, learned config) |
   |---|---|---|
   | runner wins / games (pooled) | 99 / 200 | 90 / 200 |
   | runner win rate (Wilson 95% CI) | 49.5% [42.7%, 56.4%] | 45.0% [38.3%, 51.9%] |
   | per-pair runner win rate (pair1 / pair2) | 36% / 63% | 28% / 62% |
   | median game length (s) | 17 | 20 |
   | cortex decisions / differing / vetoes | — | 1886 / 624 (33.1%) / 371 (19.7%) |

   Delta (learned - baseline): **-4.5 pp, Newcombe method-10 95% CI
   [-14.1, +5.2] pp** (conservative interval [-18.1, +9.3]; Wald [-14.3, +5.3]).
   Per-pair deltas: pair1 -8.0 pp [-20.5, +4.9], pair2 -1.0 pp [-14.2, +12.2].

   **Honest verdict (contract rule): `null-result`.** The rule for
   recommended-default is >= +5 pp AND a 95% CI excluding zero; the point
   estimate is NEGATIVE and the CI includes zero, so the learned configuration
   is NOT recommended as a default and RunCortex ships OFF, exactly as in run006.

**L4 — learning outcome, read honestly:**

- **Curve trend:** flat at the measurement resolution. Across 20 mutants in 10
  generations (attempt 2), none strictly beat the reference + 1/16; the
  generation-best series oscillates around the reference (0.375-0.625) exactly
  as binomial noise at N=16 predicts (sigma ~ 12 pp per eval).
- **Which weights moved:** none, in the end — the artifact config equals the
  defaults. The search EXPLORED mutants with every weight moved by up to
  -70%/+75% (e.g. agendaPoint 2.02-5.21, etr 2.58-6.83, k 9-37,
  vetoesBelow -0.90..+1.13; full per-mutant configs in the curve); none measured
  better. The three 0.625 "ties" moved agendaPoint/etr/k upward — the same
  direction run006's intuition would suggest — but never by more than one game.
- **Noise floor vs effect size:** the fitness function at N=16 has a measured
  reference A-B gap of 6.25 pp (attempt 1 at N=24: 16.7 pp), while the acceptance
  bar is +6.25 pp; the final evaluation at N=200/arm has a +/- ~7 pp CI
  half-width. The pipeline could therefore only have "discovered" effects of
  roughly >= 6 pp — and the evaluation says even the search itself is not worth
  that much (see below). A future learning attempt needs either a lower-variance
  fitness (paired/same-seed games, larger N, or a win-rate + margin composite) or
  many more games per eval.
- **The stronger data-driven conclusion is about the SEARCH, not the weights:**
  run006 measured search ON at +10 pp (n=100/arm, CI [-9.5, +28.8]); run 007
  measured it again (the learned config equals the default, so the learned arm
  IS "search ON with default config") at **-4.5 pp** (n=200/arm, CI
  [-14.1, +5.2]). Pooling the two experiments (search arms 143/300 = 47.7% vs
  baseline arms 142/300 = 47.3%) puts the search effect at ~0. The search
  demonstrably changes DECISIONS (33% differ from stock, ~20% vetoes, zero
  cortex errors, both runs) but not OUTCOMES. Run 007 therefore records: the
  run-decision search line was iterated via learning as agreed, and the honest
  measured answer is that neither the default nor any explored nearby config
  converts search activity into more wins at affordable compute. The human
  checkpoint (contract: iterate features/algorithm, scale compute, or park the
  line) is the user's; the ledger already records a run-008 direction proposal
  (corp-side deception learning against a runner population).

Compute actually spent (wall): L1 logging batch ~8 min (24 games); L2 learning
51 min (cancelled attempt 1) + 39.6 min (attempt 2) + ~2 min verifications;
L3 evaluation ~29 min (4 shards in parallel). Learning + evaluation combined
~122 min, inside the ~150 min contract budget; the adaptive ladder did not need
to shrink N below 16 in attempt 2 (attempt 1 shrank 24 -> 8 at its budget guard
and was then killed; both behaviors are recorded in their curves).

To re-run the whole pipeline: the three command blocks above (logger batch +
validate, learner + verify, eval shards + merge), in that order.

### 4.8 Corp deception learning (run008): opponent model, population training, mixed policy, held-out eval

Run 008 gives the CORP the run-007 treatment: parameterized deception actions,
an opponent model learned from logged runner decisions, and a self-play learner
that evolves the deception parameters against a randomized POPULATION of runner
variants. Everything ships DEFAULT OFF. The design direction (mixed policy +
held-out evaluation to expose opponent-overfitting) was user-approved in the
run-007 ledger.

**Module — `ai_corpdecept.js` (NEW, `window.CorpDecept`).** Loaded after
`ai_corp.js` by both pages (`offline.html` include + `harness/lib/inproc.js`)
and shipped by the Vercel build. Inert unless `AIOverrides.corp.deception` was
set before `Init()`. When enabled, it implements three parameterized actions at
the CorpAI's existing decision sites (via the two guarded hook sites below):

- `bluffAdvanceP` — when the corp's preference system targets an AGENDA for
  advancement, advance a non-agenda advanceable card instead (a fake advance;
  ambush-class targets preferred) with this probability.
- `rezHoldFactor` — modifier on the rez-during-run decision
  (`_iceWorthRezzing` in `Phase_Approaching`): the module computes the opponent
  model's P(run | current state); the corp HOLDS the rez with probability
  `rezHoldFactor * max(0, 0.5 - P(run)) * 2` when the stock AI would rez (the
  model says this runner-state is run-averse), and SNAP-REZZES with probability
  `rezHoldFactor * max(0, P(run) - 0.5) * 2` when the stock AI would hold.
  `rezHoldFactor` 0 = always stock.
- `ambushWeight` — when the corp's preference targets installing an AGENDA into
  a remote, install an AMBUSH-class card (`CheckSubType "Ambush"`, e.g.
  Snare!) instead with this probability (the trap where a scoring server was
  announced).

MIXED POLICY: the effective probabilities are a PERSONA drawn once per game
(uniform: `bluffAdvanceP ~ U[0, bluffAdvanceP_max]`, `rezHoldFactor ~
U[center-0.5, center+0.5]`, `ambushWeight ~ U[0, ambushWeight_max]`), never a
fixed trick; the LEARNER evolves the ranges. The opponent model
(`window.CORP_OPPONENT_MODEL`) is consumed informationally by `rezHoldFactor`
only; missing model / NaN features => neutral 0.5. Every evaluated decision is
telemetered to `CorpDecept.decisionLog` (action, persona, stock choice,
diversion, P(run), roll); any module error fails safe to the stock decision.
The offline page embeds the trained model inline at generation time
(`tools/make-offline-page.mjs` reads `harness/corp-opponent-model.json`).

**Hook — `ai_corp.js`, 21 changed lines total, two guarded sites, DEFAULT OFF.**
(1) In `Choice()`'s preference-match block, an `advance`/`install` preference
hit may be re-targeted through `CorpDecept.retargetChoice`; (2) in
`Phase_Approaching`, the stock `_iceWorthRezzing` verdict passes through
`CorpDecept.rezDecision`. Both sites first check `window.CorpDecept.enabled()`
(`AIOverrides.corp.deception` truthy) and fall through to the untouched stock
behavior otherwise — with the flag off no randomness is consumed and behavior
is byte-identical (proof below). Diff: `.gauntlet/evidence/run008/E1-hook-diff.txt`.

**Opponent model (D2).** A fresh 80-game corpus (`tools/gen-opponent-corpus.mjs`,
stock 40% / runsearch 40% / random-weight mutants 20%, both legal pairs,
`--log-decisions` on every game; 1191 decision lines) trains a logistic
regression (`tools/train-opponent-model.mjs`, pure-Node gradient descent) on
P(runner initiates a run | the logger's 18 state features). 80/20 split BY GAME:
held-out accuracy 0.903, AUC 0.821 (base rate 0.862; run-007-era cortex rows).
Artifact: `harness/corp-opponent-model.json` (raw-space weights, applied inside
the engine to raw state features). Corpus + training report:
`.gauntlet/evidence/run008/corpus/`, `opponent-model-training.json`.

**Population learner (D3) — `tools/learn-deception.mjs`.** A (1+lambda)-ES over
only 3 persona-range dimensions (run-007 noise lesson: fewer dimensions, larger
N), fitness = COMPOSITE `0.8 * corpWinRate + 0.2 * marginNorm` (margin = corp
agenda margin at game end, clamped +/-8) to cut the 6.25pp win-rate noise floor
run-007 measured. Every fitness game samples its RUNNER from the population
{stock 50%, runsearch-default 25%, per-game random weight-mutant 25%} and its
persona from the candidate ranges (mixed policy); the actual composition and
per-kind corp win rates are recorded per evaluation. Reference fitness is
measured twice at the start (the A/B gap is the honest composite noise floor).

```
node tools/gen-opponent-corpus.mjs --games 80 --workers 8 --out-dir ../.gauntlet/evidence/run008/corpus
node tools/train-opponent-model.mjs --corpus ../.gauntlet/evidence/run008/corpus
node tools/learn-deception.mjs --gens 8 --games 24 --lambda 2 --proc 8 --budget-min 40 --timeout-min 2
```

**Held-out evaluation (D4).** `harness/compare.js` gained corp-side arms
(`corpbase`, `corpdecept` with `--deception-file` +
`--opponent-model`, default the two artifacts) and `--runner-population`
(`heldout` = stock 50% + runsearch 50%, `training` = the fitness composition,
`stock`, `runsearch`); opponents are drawn deterministically per game index so
both arms face the same opponents; `--delta-side corp` (auto for corp arms)
makes the delta and verdict CORP win-rate based; per-opponent splits are in
`summary.armOpponent` / `summary.perOpponentDelta`. Verdict rule unchanged:
recommend default-ON only if the pooled delta >= +5pp with the method-10 95% CI
excluding zero ON HELD-OUT runners. Run008 results (HONEST NULL, deception
stays default-OFF):

- **Held-out evaluation** (400 games = 100/arm/pair, runner population
  `heldout` = stock 50% + default-config runsearch 50%, deterministic
  per-index opponent draw shared by both arms): baseline corp wins 104/200
  (52.0%, Wilson 95% [45.1, 58.8]); deception corp 96/200 (48.0%,
  [41.2, 54.9]). **Delta -4.0pp, TRUE Newcombe method-10 95% CI
  [-13.7, +5.8]** → includes zero → **verdict: null-result** (far from the
  +5pp recommend-default bar). Per-opponent splits: vs runsearch +6.5pp
  [-7.8, +20.5] (92 games/arm); vs stock -13.0pp [-25.6, +0.4] (108 games/arm).
  Per-pair: pair1 -2.0pp [-15.3, +11.4]; pair2 -6.0pp [-19.2, +7.5].
- **Generalization gap** (dedicated 120-game run, 60 games/arm over both
  pairs, runner population `training` = the fitness composition stock
  50%/runsearch 25%/mutant 25%): baseline 29/60 (48.3%) vs deception 27/60
  (45.0%), **delta -3.3pp [-20.5, +14.1]**. Gap = training-population delta
  minus held-out delta = -3.3pp - (-4.0pp) = **+0.7pp — no detectable
  opponent-overfitting**, and equally no benefit on the runners the learner
  actually trained against. The learner's accepted generation-1 gain
  (composite fitness 0.539 vs 0.279 reference at N=24, noise floor 0.093)
  therefore does not replicate at evaluation N — the run-007 noise lesson
  again: small-N fitness deltas are not effects.
- Deception telemetry was live and honest in every ON game: 174 of 1027
  evaluated decisions actually diverted from the stock choice across the 200
  held-out deception-arm games (all three action classes fired); zero module
  errors, and exactly zero evaluations in the OFF arm.

Raw records (per-game JSONs, critic-recomputable):
`.gauntlet/evidence/run008/eval-heldout/` (summary + 400 games),
`.gauntlet/evidence/run008/gap-trainingpop/` (summary + 120 games),
independent recomputation `critic-recompute-eval-heldout.json` /
`critic-recompute-gap-trainingpop.json` (0 diffs vs harness summaries). The
contract's human checkpoint applies: iterate the deception parameterization,
scale compute/population, or park the line — the honest measured answer at
this compute is that parameterized deception does not convert into corp wins.

```
node harness/compare.js --arms corpbase,corpdecept --games 100 --runner-population heldout --label run008-eval-heldout --out-dir ../.gauntlet/evidence/run008/eval-heldout --workers 10 --timeout-min 8 --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box"
node harness/compare.js --arms corpbase,corpdecept --games 30 --runner-population training --label run008-gap-trainingpop --out-dir ../.gauntlet/evidence/run008/gap-trainingpop --workers 10 --timeout-min 8 --pair "precon:Pecularity,precon:Bowel Movements" --pair "precon:nrdb-pt-untaian-shell-gameassets-core-battle-box,precon:nrdb-topan-core-battle-box"
```

**Enable / re-run.** Deception is OFF by default and there is no UI switch
(like RunCortex). Enable per game with
`AIOverrides.corp.deception = true` (the harness arms above do this); the
persona ranges come from `harness/learned-deception.json` (`--deception-file`)
and default to `{bluffAdvanceP_max: 0.5, rezHoldFactor_center: 1.0,
ambushWeight_max: 0.5}`. Default-OFF proof: `.gauntlet/evidence/run008/smokes/smoke-off/`
(zero deception evaluations in every game); variant-ON telemetry:
`.gauntlet/evidence/run008/smokes/smoke-on/`.

### 4.9 Learned position evaluator + paired evaluation (run 011)

Run 011 answers "can the AI learn to evaluate positions?" with a supervised
model instead of black-box parameter search, and replaces the noisy A/B
evaluation with same-seed pairing.

**Paired harness (V1).** `harness/lib/inproc.js` can inject a seeded PRNG as
`Math.random` before the engine loads (`--seed-base` in
`harness/paired-compare.mjs`), so both arms of a pair play identical deals.
Honest sensitivity result: pairing did NOT narrow CIs at these effect sizes
(paired-normal/unpaired-Newcombe width ratio 1.44; 128 of 150 pairs were
ties - identical deals mostly produce identical outcomes, so win-rate noise
is draw-dominated). The pairing machinery is kept; the sensitivity claim is
dropped per contract.

**Corpus + model (V2/V3).** 500 logged games (7004 decision rows) ->
logistic regression (pure Node, split by game): held-out accuracy 0.645,
balanced accuracy 0.642, AUC 0.696 (per-game mean 0.784) vs majority baseline
0.521 - the model HAS real (modest) predictive skill, unlike run-008's
imbalanced split. Artifact: `harness/learned-eval.json`; trainer:
`node tools/train-eval-model.mjs`; integration verifier:
`node harness/verify-evalmodel-run011.mjs`.

**Integration (default OFF).** `window.LEARNED_EVAL` (embedded in
offline.html at generation time, same pattern as the opponent model) +
`RunCortex.config.evalModel = {enabled, blend: 0.5, scale: 4}` blends the
model's win-probability delta into candidate scores. Proven inert without
config (`verify-evalmodel-flipseed.json`), and proven to change decisions
when enabled.

**Paired evaluation (V4) - honest null, both comparisons.**
- vs baseline: learned 48.0% vs baseline 48.7% over 150 pairs (300 games,
  seed-base 320000): delta -0.7pp, method-10 [-11.8, +10.5], sign test p=1.0
  (91 ties).
- vs stock RunCortex: learned 50.7% vs search 53.3% over 150 pairs (300
  games, seed-base 310000): delta -2.7pp, bootstrap CI [-8.7, +3.3], sign
  test p=0.52 (128 ties).
Verdict per contract rule: null - the learned evaluator ships default-OFF and
does NOT add on top of stock search. Interpretation: at this compute scale
the win-rate signal is draw-dominated (61% of same-deal pairs tie), so
decision-level differences cannot surface in win-rate; the corpus and the
paired harness remain the durable assets for any future attempt with
different metrics (margin-based, decision-level) or much larger compute.

### 4.10 Meta grounding + disagreement mining (run 012)

Run 012 grounds the evaluation gauntlet in the real 2026 tournament meta, then
mines the decision-level disagreements between the three AI components
(stock heuristic, RunCortex search, learned evaluator) on logged games over
those decks. No engine changes shipped (see M4 below).

**M1 - meta rank (`tools/meta-rank.mjs`).** Ranks current-format archetypes
from the public AlwaysBeRunning JSON API joined with NRDB decklists.
What ABR exposes (probed 2026-10-20): `/api/tournaments` (listings without
per-tournament format/cardpool fields; most rows undated recurring meetups),
and `/api/entries?id=<id>` (per-player `rank_swiss`/`rank_top`, deck titles,
identity ids, OPTIONAL self-reported NRDB decklist URLs). ABR does NOT expose
round-level results, tables or win/loss data - NO round-level win rates exist
anywhere public, so the ranking is PLACEMENT-weighted only (win 8 / top4 5 /
top8 3 / top-cut 2 / swiss 1.2/0.8 / other 0.4). Curated bounded set: 15
events 2026-06..2026-10 straddling the 26.09 ban-list update (Worlds 2026
5464 with 284 entries, continentals, megacities, the NRO//TEST Startup
League), each stamped pre/post-26.09. 140 decklists fetched from the NRDB
public API and classified with `harness/lib/formats.js`; honest coverage
result: 0 of the fetched CURRENT-era Standard top-cut lists are fully
implemented in `sets/*` (missing packs: ms/tai/rwr/ph/df/ur - histogram in
`meta-rank.json`), 5 usable lists in the NRO Startup League pass. Outputs:
`.gauntlet/evidence/run012/meta-rank.json` + `meta-rank-report.md` (sources +
dates per event). Spot-checks: (1) 5 tournaments re-fetched live, entry sets
identical (`raw/spotcheck/spotcheck-summary.json`); (2) a full verbatim
re-run of the tool on the same day re-fetched every event and produced
IDENTICAL archetype rankings (the decklist fetch total varies between
invocations with the representable-pass fetch cap - 140 lists in the
generation run, 110 in the re-run; the 0-usable-among-current-era-Standard
conclusion and every ranking are unchanged; both JSONs preserved under
`raw/`).

**M2 - meta gauntlet (`harness/gauntlet-run012.mjs`).** 10 decks / 10
archetypes, 5 per side, all meta-ranked, fully implemented and format-legal:
corp = NBN: Reality Plus (Worlds 3rd), Weyland Built to Last, Nebula Talent
Management (post-Offworld-ban), LEO Construction (Worlds Startup 1st), The
Zwicky Group (Uppsala CTK 1st, new import); runner = Magdalene Keino-Chemutai
(Worlds Startup 1st), MuslihaT (Startup 3rd), Zahya Sadeghi (Worlds cut),
Topan (core battle box), Tao Salonga (NRO Startup League, new import). Every
deck proven playable with a real in-proc AIA game (ap 6, 0 fatal; 10/10
passed). The 27 meta identities with NO representable deck are recorded with
placement weights in `gauntlet.json` (representableGaps).

**M3 - disagreement mining (`harness/mine-disagreements-run012.mjs` +
`tools/audit-mining-run012.mjs` + `tools/recount-mining-run012.mjs`).** 50
fully-logged games (all 25 corp-runner pairings x 2 seeds, k=24, learned
evaluator blend 0.5/scale 4 - the run-011 shipped config): 768 RunCortex
decisions, 0 fatal, all validating against the run-007 schema
(`harness/validate-decisions.js`: 1417 lines, 0 malformed). The logger's
run-012 extension passes RunCortex `trace.evalModel` through per decision
(`pByCandidate[]` aligned with candidates). Per-decision disagreement kinds:
cortex-vs-stock (`differedFromDefault`, weighted by |blended score gap|) and
model-vs-search (argmax P(win) != chosen, weighted by |meanEV gap|).
Results: 184 cortex-vs-stock disagreements (24.0%; classified heuristic-suspect
53 / search-suspect 91 / ambiguous 40), 295 model-vs-search flips (38.4%,
mean weight 2.38, max 10.97). Outputs: `mining-report.json` +
`mining-AUDIT.md` (top-24 divergent states with full board context, candidate
tables, per-archetype divergence tables, and a cortex-vs-stock candidate pool
ranked by D weight). Independent recount (no shared code): totals, top-24
state-by-state roles and a 59-state sample all MATCH (`raw/recount-mining.json`).

**M4 - safe corrections: none qualified (honest outcome).** Every top
cortex-vs-stock state was reviewed against basic card math for a
wrong-before/right-after defect (run-010 Beta Build precedent). None is a
provable bug: the divergences are the DESIGNED heuristic-vs-search difference
(stock orders by potential without pricing ice cost - ranks 1-6 of the D
pool), per-spec veto behavior (`scoreOf(best) <= vetoesBelow`), known
run-011 model noise (per-candidate P(win) only differentiates via ice /
potential), and rollout sampling poverty on heavy states (3-6 samples after
the time budget). 0 of the 40-line correction budget used; `ai_corp.js`,
`ai_runner.js`, `ai_runcortex.js` are untouched this run. Candidates for
human review (C1-C4, with evidence pointers):
`m4-corrections-review.json` - C1 price ice cost into the stock server order
(redesign, changes the default-OFF path), C2 per-candidate features for the
learned evaluator (retrain; run-011 showed no lift), C3 raise the rollout
budget (invalidates cross-run comparability), C4 logger ice-capture by
candidate identity (cosmetic null-ice edge, 1 of 768 decisions).

**M5 - meta evaluation (`harness/eval-matrix-run012.mjs` +
`tools/meta-eval-report-run012.mjs`).** Per-archetype AI-vs-AI win rates on
the gauntlet for the stock arm (search OFF, 25 pairings) and the search arm
(search + learned evaluator, the 50 mining games), joined in
`meta-eval-report.md`. HONEST FRAMING: these are scripted-seat ordering
comparisons at ap 6 with 1-2 games per pairing - NOT human-comparable
absolute strength, and single-game variance is reported per cell.

**Reproduce** (from `chiriboga/`; generation order top to bottom - the
network commands refetch live ABR/NRDB data, the analysis commands are
deterministic over the existing artifacts):

```
node tools/meta-rank.mjs                                  # M1 (refetches ABR+NRDB, regenerates meta-rank.json/report)
node tools/meta-rank.mjs --report-only                    # re-render the report from the existing meta-rank.json (no network)
node harness/gauntlet-run012.mjs                          # M2 (manifest + 10 playability proof games)
node harness/mine-disagreements-run012.mjs                # M3 games (~30 min, overwrites decisions-run012.jsonl)
node harness/validate-decisions.js .gauntlet/evidence/run012/decisions-run012.jsonl --report .gauntlet/evidence/run012/raw/decisions-validation.txt
node tools/audit-mining-run012.mjs --top 24               # M3 report + AUDIT from the raw JSONL
node tools/recount-mining-run012.mjs --top 24 --sample 60 # M3 independent recount gate (exit 0 = MATCH)
node harness/eval-matrix-run012.mjs                       # M5 stock arm (25 games)
node tools/meta-eval-report-run012.mjs                    # M5 join -> meta-eval-report.md
node harness/batch-run012.mjs                             # regression batch (10 games, 0 fatal)
node harness/serve.js 8123 &                              # then: node harness/verify-menu.js (menu suite)
```

### 4.11 Top-meta identity tranche (run 013)

Third expansion tranche: unlock the top-weight archetypes the run 012 meta
ranking (section 4.10) found **unrepresentable**. Six weight-8 identities were
ranked by the exact missing-card union of their recent tournament lists
(identity + every support code absent from the 368-card implemented pool;
>=3 lists per archetype, NRDB public decklist API):

| Archetype | Side | Identity | Union (pre) | Decision |
|---|---|---|---:|---|
| Sebastião Souza Pessoa: Activist Organizer | runner | 34066 | **19** | implemented |
| Nuvem SA: Law of the Land | corp | 34121 | **22** | implemented |
| Virtual Intelligence, P.I.: “You Can Call Me Vic” | runner | 36009 | **24** | implemented |
| Méliès U: Only the Brightest | corp | 36036 | 21 | excluded — identity is on the **Standard ban list** |
| René “Loup” Arcemont: Party Animal | runner | 30001 | 31 | not selected (identity already implemented; larger union) |
| Ob Superheavy Logistics: Extract. Export. Excel. | corp | 33057 | 34 | not selected (larger union) |

The three selected = the smallest Standard-legal unions (union checkpoint max
34 < ~80, no stop). The selected tranche is **61 cards**: 58 support codes +
3 identities, set-equal to the fetched catalog `carddata/newerpacks.json`
(61 NRDB card-API records, fetched 2026-10-20). `tools/format-data.json`
extends the Standard pool (`sg,elev,df,tai,rwr,vp` + `ur,urbp,ms,ph`); Startup
stays `sg,elev,vp` (all three target decks are Standard-only).

**Implementation.** All 61 cards live in **NEW** set files under their real
NRDB codes, with basic `//**AI` hints for identity-relevant mechanics:
`sets/newerpacks.js` (the df/ur/urbp/ms/ph/vp share, including the urbp 27xxx
range) and `sets/liberation013.js` (the tai/rwr share incl. the Sebastião and
Nuvem identities). `sw.js`-independent registry additions in `config.js`:
`newerpacks` (idRange `[27000, 27999]`) and `liberation013` code `lib13`
(idRange `[34001, 34130]`). `sets/liberation.js` stays byte-identical to
run010 (the appended 30-card draft block of a prior session was relocated).
No existing card implementation or engine file changed; the implemented pool
grows **368 -> 429**.

**Imports + menu.** Three representative tournament lists are imported,
classified and usable (Standard-legal, 0 missing codes): "girl wants her third
transfer back [6-1, 1st at EMEA online]" (NRDB 98352, Sebastião), "Still no
targets - 1st @ Worlds Showdown, 1st @ APAC Conts" (NRDB 98725, Nuvem SA),
"You can call me Vic - 4th at EMEA" (NRDB 98069, V.I.P.I.). `menu.html` is
regenerated from `tools/make-menu-page.mjs`; `harness/verify-menu.js` is green.
Deck classification after the tranche: 102 precons, 30 Standard-legal.

**Honest remaining gaps.** Post-implementation residual unions (packages the
tranche did not cover): Méliès U 19 (incl. the banned identity 36036), René 13,
Ob Superheavy 22. The two remaining weight-8 archetypes are therefore still
only partially representable. Batch framing: 8 seeded in-proc games, **0
fatalErrors**; 5 ended in a win, 3 ended without a win
(NO_WIN_WITHIN_TIMEOUT, 0 fatal) — same treatment as the run012 regression
batch, and the per-game summaries record every outcome.

**Reproduce** (from `chiriboga/`; the importer and the union tool with fresh
fetches are the only network commands):

```
node harness/smoke-tranche-run013.js --out smoke-run013.json      # 61/61 smokes PASS
node harness/verify-run013-implementation.mjs --out ../.gauntlet/evidence/run013/E2-implementation-coverage.json
node harness/verify-run013-format.mjs --out ../.gauntlet/evidence/run013/E3-format-check.json
node harness/unions-run013.mjs --offline --out ../.gauntlet/evidence/run013/unions-run013-recheck.json
New-Item -ItemType Directory -Force -Path ..\.gauntlet\evidence\run013\raw\rerun-precons, ..\.gauntlet\evidence\run013\raw\rerun-decks | Out-Null
node tools/import-nrdb-deck/index.mjs https://netrunnerdb.com/en/decklist/98352 https://netrunnerdb.com/en/decklist/98725 https://netrunnerdb.com/en/decklist/98069 --report ../.gauntlet/evidence/run013/E3-import-report.json --out-precons ../.gauntlet/evidence/run013/raw/rerun-precons --out-decks ../.gauntlet/evidence/run013/raw/rerun-decks   # 3/3 USABLE, Standard-legal
node tools/classify-precons.mjs --out ../.gauntlet/evidence/run013/E3-classification-report.json --md ../.gauntlet/evidence/run013/classification-run013.md
node harness/proof-run013.mjs --out-dir ../.gauntlet/evidence/run013/proofs --seed 4242              # 3/3 proofs, 0 fatalErrors
node harness/batch-run013.mjs --out-dir ../.gauntlet/evidence/run013/batch --seed 1000 --timeout-min 5   # 8 games, 0 fatalErrors, 5 wins + 3 anchor-cap timeouts
node harness/serve.js 8123 &    # then: node harness/verify-menu.js --server http://127.0.0.1:8123
```

The import command above redirects its outputs to the scratch dirs under
`.gauntlet/evidence/run013/raw/` (created by the preceding `New-Item` — the
importer does not create custom output dirs itself) so a verbatim re-run never
duplicates the tree: the importer seeds slug uniqueness from existing nrdb
files and never overwrites, and the 2026-10-20 build ran it without the
redirects, writing the three canonical `precons/nrdb-*` +
`harness/decks/nrdb-*` files that the menu, `E3-import-report.json` ("3/3
decks fully implemented and usable") and the play suites reference.

The proof and batch games are deterministic when `--seed` is passed (the in-proc
harness supports RNG seeding; without it, AI rng lands on unseeded entanglement
states and a pairing can legitimately reach the ap-6 anchor cap without a win -
observed with fresh unseeded reruns, 0 fatalErrors each time). Seeded outcomes
for the recorded evidence: proofs = corp wins for games 1 and 2, runner win for
game 3 (all 3 seeded runs completed quickly, 0 fatalErrors); batch = 5 completed
wins + 3 anchor-cap timeouts (NO_WIN_WITHIN_TIMEOUT, 0 fatalErrors in every
game; each batch game seeds 1000+index; `--timeout-min 5` only shortens the
harness wall-clock cap of the stalling pairings, outcomes unchanged) - outcomes
are recorded per-game in
`../.gauntlet/evidence/run013/batch/batch-run013-summary.json`.

### 4.12 René "Loup" Arcemont + Ob Superheavy Logistics tranche (run 014)

Fourth expansion tranche: complete the two weight-8 archetypes run 013 left
un-representable (their unions 13 / 22 were computed exactly by run013's
offline recheck, section 4.11, cmd 4). René's identity (30001) was already
implemented; Ob Superheavy Logistics' identity (33057) was still missing and is
implemented in this tranche. That makes the tranche **35 cards** — 13 René
support cards + 21 Ob support cards + the Ob identity — all NEW, in one NEW set
file under real NRDB codes — `sets/tranche014.js`
(set code `tp14`). Union evidence: `.gauntlet/evidence/run014/unions-run014-round1.json`
(built from the cached run012/013 decklist cache; no fresh NRDB fetches were
needed — `unions-run014.mjs --offline`):

| Archetype | Side | Identity | Tranche cards | Union evidence |
|---|---|---|---:|---|
| René "Loup" Arcemont: Party Animal | runner | 30001 | 13 support | 26009, 26069, 26070, 26079, 26085, 26089, 32002, 33009, 33066, 33071, 34022, 34067, 36005 |
| Ob Superheavy Logistics: Extract. Export. Excel. | corp | 33057 | 21 support + identity | 33057, 33047, 33058, 33059, 33060, 33108, 33121, 33122, 33124, 33126, 33127, 34120, 34122, 34130, 26059, 26062, 26105, 26122, 32007, 34061, 34063, 34064 |

Two of the 35 are msbp *bundle printings* of cards that already exist as ms
printings elsewhere in the pool — Retrainer-revisions, not new cards:

- `32002` Revolver (msbp #2) = ms `33018` Revolver (`sets/midnightsun.js`),
- `32007` Azef Protocol (msbp #2) = ms `33058` Azef Protocol (in this tranche).

msbp is deliberately **outside** the format pools, so decks playing these
printings are not Standard-legal — the pools are unchanged this run (Standard
`sg,elev,df,tai,rwr,vp,ur,urbp,ms,ph`, Startup `sg,elev,vp`); no new packs.

**Implementation.** All 35 cards live in `sets/tranche014.js` under their real
NRDB codes with basic `//**AI` hints on 22 of them (both agendas 33121/34122 and
the identity 33057 included). `config.js` gains exactly one registry row plus
its comment block (+6 lines in total: 5 comment + 1 registry row): `tranche014`
code `tp14`, `hidden`, `untested`, idRange `[26009, 34130]`. No engine file
changed, no existing card implementation changed, `sets/liberation.js`
untouched, `sw.js` untouched; the implemented pool grows **429 -> 464**.

**Documented approximations.** 22 notes on 21 of the 35 cards carry an
explicit `HONEST APPROXIMATION` marker in `sets/tranche014.js` where the
printed card text asks for a mechanic the engine has no hook for. Each row
below names the card that owns the note (a note inside a card's own body
belongs to that card; a note written above a card definition belongs to that
next card) and quotes the note as written in the source file, so no
approximation is silent:

| Card | Code | Approximation (as implemented) |
|---|---|---|
| shared helper `run014DiscountRefund` (discount cards: Gachapon 26069, Simulchip 26085, Tucana 34064, Eminent Domain 34122) | shared helper | refund may exceed the amount actually paid for very cheap installs (max overshoot = N - 1 credits). |
| Gachapon | 26069 | the 2-credit discount is implemented as pay-then-refund (see run014DiscountRefund). The 3 shufflebacks and the removals happen after the install (or after declining) resolves. |
| Simulchip | 26085 | "already trashed this turn" tracking fires off tranche-side program trashes only (Anvil/ZATO; runner-side program trashes in other sets are not observed). If nothing was tracked, the cost trash is offered as a decision; the "ignore" clause then means "only offered when a tranche-side program wasn't already trashed". |
| Afterimage | 26079 | the stealth-restricted credit-spends are modeled as normal credit spends (the engine has no stealth-pool hook). |
| Penrose | 26089 | the stealth-restricted strength boost is a normal credit spend (engine has no stealth-pool hook). |
| Light the Fire! | 33009 | "cards in the root lose all abilities" is enforced through the engine's modifyHasAbilities hook, which suppresses the card's abilities list but NOT its automatics/modify hooks (no way to suppress those from a different card). CORE damage cost is unpreventable. |
| Hush | 33071 | "cannot gain abilities" has no hook; the implemented suppression is active-state only (a patch on a newly-rezzed or another card isn't blocked). The engine's modifyHasAbilities hook is used, which suppresses the host's ability list from a tranche-side check. |
| AirbladeX (JSRF Ed.) | 34022 | the second interrupt (preventing an encounter-time ice ability) has no engine hook; only the net-damage prevention is implemented. Trash-when-empty is enforced after each counter spend through the onCreditsSpent-style counter drain. |
| Eye for an Eye | 34067 | "play only if not tagged" is enforced; "Access ->" ability is implemented as an automaticOnAccess offer on this event (from the heap, availableWhenInactive) once per successful run, at the first breach of the run. The tag/access bonus is a delayed-addTags + modifyBreachAccess armed on success (direct in the run-success response). |
| Lampades | 36005 | the stealth-only spend restriction is a normal credit spend (engine has no stealth-pool hook). |
| Ob Superheavy Logistics: Extract. Export. Excel. | 33057 | (updated run 028, section 4.31) "when you trash" = a trash during the Corp's turn; a trashed card with printed rez cost 0 offers nothing; "ignoring credit costs" follows the engine's ignoreAllCosts flags; the AI Corp keeps the automatic destination (existing remote with empty root, else HQ / new remote for ice). The human Corp chooses the cost (several trashed cards), the card and the server. |
| The Holo Man | 34120 | "not installed any cards from HQ this turn" is tracked corp-installs-any (the per-install origin can't be observed inside the engine's post-install hook); installing anything today means "has installed from HQ", fewer counters. Move offer declines when it doesn't improve the position (AI). |
| Azef Protocol | 33058 | the score-time additional cost can't gate the score action (no "additional score cost" hook). It is modeled as: (a) scoring is forbidden when no OTHER installed corp card exists (via modifyCannot "score"), and (b) the trash happens as a forced response right after scoring. Both ms/msbp printings share this body. |
| Envelopment | 33060 | the AI treats the counters-End set as one end-the-run subroutine of unknown difficulty (no model of multiple subs). |
| Envelopment | 33060 | the counters-End set models as one end-the-run subroutines row of unknown difficulty; the printed trash sub is separate. |
| Hostile Architecture | 33122 | automaticOnTrash fires for all trash events on this file's cards; the Corp-turn gate isn't necessary (Runner trash is determined by the player who initiates trashing). Detection of "Runner trash" uses playerTurn == runner. When the Runner's own card is trashed (e.g. grip damage) Hook is inactive: not harmful. |
| Anvil | 33124 | the "cannot break printed subroutines" uses the engine's ice-level cannotBreakUsingAIPrograms flag for the AI and a post-break rollback (Avshar precedent) for precise enforcement — a tranche "printed" sub break cannot be re-checked at enumeration time. [subroutine] Gain 1[credit]. The Runner loses 1[credit]. [subroutine] The Runner trashes 1 of their installed cards. |
| Tucana | 34064 | the engine has no "scored agend FROM server X" hook (intent score loses its origin on the response phase). The steal case is exact (attackedServer match). The score case is approximated to any remote-root agenda score while Tucana is on a remote server (over- broad, documented). The -3 credit discount is pay-then-refund. |
| Reduced Service | 26062 | the engine has no run-additional-cost hook. Modeled as: (a) running is forbidden via modifyCannot when the Runner cannot afford the extra; (b) the extra is auto-deducted at run start; (c) successful central runs decrement the counter. |
| Hafrún | 33108 | the engine has no per-card break-ban hook; the ban is enforced inside the tranche breakers' Enumerates (which check this.bannedBreakerCard) and via a documented approximation for the AI (the chosen card gets a run-scoped cannotBreak flag our tranche breakers read). Legacy breakers unaffected (documented). |
| ZATO City Grid | 33127 | the engine cannot grant abilities to other cards; the grid's encounter hook offers the choice on the ENCOUNTER side. "This ice" maps to the ice being encountered. |
| Eminent Domain | 34122 | "paying a total of 5 credits less" is pay-then-refund; the search-install-rez uses the shared run014CorpInstall AndRez helper with ignoreAllCosts for both halves. |

Every approximation above is a *mechanic-fidelity* limit, not a playability
limit: all 35 cards pass their per-card smoke (35/35), the proof and batch
games complete with 0 fatalErrors, and no approximated card blocks a deck from
being imported or played.

**Imports + menu.** Eight tournament lists are imported, classified and usable
(4 runner René-30001 + 4 corp Ob-33057, all Standard-legal, 0 missing codes):
Nuttah 97366, Flood PD 98943, Werewolf 98165, Butt Sweat 98676; Unemployment b
98698, JUST MAKE MONEY OB 98616, Third Time's the Charm 99004, A Twink With No
Dreams 98113. Import reports: `.gauntlet-import-run014.json` (first pass ran
while the tranche was still being added — the four corp rows record BROKEN
and were superseded by the second pass) and `.gauntlet-import-run014-ob.json`
(4/4 USABLE; both archived under `.gauntlet/evidence/run014/`).
`menu.html` regenerated; `harness/verify-menu.js` green; classification after
the tranche: 110 precons, all 8 new Standard-legal. Run 014's proof and batch
used Flood PD 98943 and Werewolf 98165 (René) plus Unemployment b 98698 and
JUST MAKE MONEY OB 98616 (Ob). Nuttah 97366 and Third Time's the Charm 99004
are played in the run 015 checks below. Butt Sweat 98676 and A Twink With No
Dreams 98113 stay imported and classified, and were not part of either run's
play pairs.

**Honest remaining gaps.** Both target archetypes are fully representable
(this was the point of the tranche: René union 13/13, Ob union 22/22
implemented). Méliès U (36036) remains out of scope — its identity is on the
Standard ban list and its support cards are not targeted this run.

**Run 014 play-game substitutions, stopped in run 015.** Run 014 swapped two
pairings because existing-card hooks stalled AI-vs-AI play. Those hooks are
not tranche cards. Run 015 stops both stalls on the cards themselves. No
engine file changed.

1. **Boomerang install loop** (was proof game 1). Installing Boomerang 26075
   (`sets/newerpacks.js`) used to open a `DecisionPhase` from
   `automaticOnInstall` while `finishInstall` was still on Trash Before
   Install. That decision's `next` was the pending Runner Install phase, so
   resolving it spent the click and the install cost again. Baseline seed
   4242, Nuttah versus NBN, logged 334353 `Runner installed Boomerang` lines,
   runner clicks down to -334352, and `ERROR: .automaticOnInstall on Boomerang
   should not be automatic (it includes a phase change)`. The choice now
   comes from `responseOnInstall`. `automaticOnInstall` returns immediately
   on Trash Before Install, and a direct Resolve outside that phase still
   opens the decision the run 013 smoke calls. Round-1 log
   `.gauntlet/evidence/run015/round1/s1-boomerang.log`: Nuttah
   (`harness/decks/nrdb-original-nuttah-1st-and-undefeated-sydney-megacity.json`)
   versus NBN (`harness/decks/nrdb-nbn.json`), seed 4242, ap 6, speed 0,
   timeout 180000 ms. One `Runner installed Boomerang` line, one
   `Boomerang attuned to Doomscroll` line, no Boomerang automaticOnInstall
   error, no `No valid commands available`, no `Null command`, fatalErrors
   empty, outcome **RUNNER_WINS** (987 lines). Runner clicks and runner
   credits stay non-negative (minima 0 clicks and 5 credits). One corp click
   sample of -1 is taken on the AI line `there is a card to play that
   benefits from tags`, while `_potentialTagPunishment` holds a hypothetical
   `corp.clickTracker` and `corp.creditPool`. That function restores both of
   those pools before it returns. That restored sample is not the Boomerang
   stall. The stall signature is a reinstall loop or a negative runner click
   or credit pool. The game ends at runner 2 clicks / 51 credits and corp 0
   clicks / 7 credits.
2. **Hannah run-end empty-options stall** (was batch pair 4). Hannah "Wheels"
   Pilintra (34008, `sets/liberation013.js`) set `runUnsuccessful` false
   inside `responseOnRunEnds` Enumerate. The trigger path enumerates again
   when the choice is taken, that second call returned no choices, and the
   runner decision logged `ERROR: No valid commands available` then
   `ERROR: Null command`. Enumerate now only reads the flag. Resolve clears
   it and then adds the tag. Round-1 log
   `.gauntlet/evidence/run015/round1/s2-hannah.log`: Third Time's the Charm
   (`harness/decks/nrdb-third-time-and-039-s-the-charm-8th-and-top-ob-at-worlds-2026.json`,
   deck title `Third Time&#039;s the Charm (8th and Top Ob at Worlds 2026)`)
   versus Flood PD
   (`harness/decks/nrdb-flood-pd-is-a-good-deck-4th-at-worlds-2026.json`),
   seed 1007, ap 6, speed 0, timeout 480000 ms. No `No valid commands
   available`, no `Null command`, fatalErrors empty, outcome **RUNNER_WINS**
   (1603 lines). The log still has four
   `.responseOnRunnerTurnBegins on Solidarity Badge should not be automatic`
   lines. That warning is not this stall.

The two run 014 substitute pairings still finish, same seeds, timeout
480000 ms, ap 6, speed 0. Flood PD versus NBN, seed 4242:
`.gauntlet/evidence/run015/round1/s3a-flood-nbn.log`, outcome **RUNNER_WINS**
(702 lines), fatalErrors empty, no Boomerang reinstall and no Hannah
empty-command pair. JUST MAKE MONEY OB
(`harness/decks/nrdb-just-make-money-ob-5-3-4th-worlds-showdown.json`) versus
Flood PD, seed 1007: `.gauntlet/evidence/run015/round1/s3b-jmm-flood.log`,
outcome **CORP_WINS** (1273 lines), fatalErrors empty, neither stall
signature. That log also has one Solidarity Badge warning and one
`.automaticOnRez on Hafrún should not be automatic` line. Those cards were
not changed.

**Engine facts established this round (insert-only documentation;**
**no engine edits were made):**

- Ice install cost is the 0-based position depth: `utility.js InstallCost`
  (utility.js:2697-2711) returns the **position** for ice cards (the number of
  cards already installed in the destination server: innermost ice free, each
  further ice +1) and ignores the printed `installCost` on the ice branch
  entirely; non-ice cards pay their printed `installCost`. The runner AI's
  install planning uses the same figure.
- Damage prevention is a single opportunity per `Damage()` event: `Damage`
  (mechanics.js:1026-1060) opens one `OpportunityForAvoidPrevent` pass over
  `responsePreventableDamage`, and several prevention abilities can respond
  inside that one opportunity (e.g. AirbladeX 34022 together with Net Shield),
  so the amount is prevented only if the responses cover it.
- `SpendClicks`/`SpendCredits` (mechanics.js:1145/1251) subtract without a
  lower bound in phase-external flow: counters CAN go negative when a
  resolution path re-spends outside the action-gating decisions. Guards
  elsewhere assume gate-mediated flow; noted as an engine caveat, not edited.
- The importer's precons-pool regex seeds slug uniqueness from files already
  present: a verbatim re-import without fresh `--out-precons`/`--out-decks`
  redirects duplicates the tree (see run013 precedent). The run014 imports
  therefore wrote straight to `precons/` + `harness/decks/` once (canonical)
  and never re-imported; reports archived instead of re-runs.

**Reproduce** (from `chiriboga/`; everything below is network-free):

```
node harness/check-run015-round1.mjs
node harness/smoke-tranche-run014.js --out ../.gauntlet/evidence/run014/smoke-tranche-run014.json     # 35/35 PASS
node harness/verify-run014-implementation.mjs --out ../.gauntlet/evidence/run014/E2-implementation-coverage.json  # PASS (35/35 checks)
node harness/verify-run014-format.mjs --out ../.gauntlet/evidence/run014/E3-format-check.json         # PASS (15 checks; msbp clone exemption)
node harness/proof-run014.mjs --out-dir ../.gauntlet/evidence/run014/proofs --seed 4242 --timeout-min 5   # 2/2 (run 014 pairings)
node harness/batch-run014.mjs --out-dir ../.gauntlet/evidence/run014/batch --seed 1000 --timeout-min 5    # 8/8 (pair 4 = JMM vs Flood PD)
node tools/classify-precons.mjs --out ../.gauntlet/evidence/run014/classification-report.json --md ../.gauntlet/evidence/run014/classification-run014.md  # 110 decks
# terminal 1 (leave running):  node harness/serve.js 8123
# terminal 2 (after the server prints its URL):
node harness/verify-menu.js --ap 6 --timeout-min 25 --out ../.gauntlet/evidence/run014/E3-verify-menu.json
```

`node harness/check-run015-round1.mjs` reruns the seam and the four games in
this section. From `chiriboga/` it exits 0 and writes
`.gauntlet/evidence/run015/round1/`. The run recorded here is seed 4242
Nuttah versus NBN **RUNNER_WINS**, seed 1007 Third Time's the Charm versus
Flood PD **RUNNER_WINS**, seed 4242 Flood PD versus NBN **RUNNER_WINS**, and
seed 1007 JUST MAKE MONEY OB versus Flood PD **CORP_WINS**.

The proof and batch games are deterministic when `--seed` is passed (same
in-proc seeding as run013): proofs re-run reproduce Flood PD -> RUNNER_WINS
(702 log lines) and Unemployment b -> CORP_WINS (1068 lines) at ap 6; batch
pairs (per-game seeds 1001..1008) reproduce with every game won by one side
in 0 fatalErrors. `verify-run014-format.mjs` fixes the msbp clone targets
33018/33058 from the full catalog (not the tranche-sliced map) so the
Revolver mapping cannot regress silently.

### 4.13 Stock seat matrix versus placement (run 016)

Stock AI versus stock AI, in process. These figures are stock seat results, not human win rates. AlwaysBeRunning has no round win rates. This section does not claim an AI improvement, and no AI file was changed.

Commands (from `chiriboga/`):

```
node harness/eval-matrix-run016.mjs
node harness/report-run016.mjs
```

`node harness/eval-matrix-run016.mjs` calls `runOneGameInproc` with ap 6, speed 0, timeout 120000 ms, and does not pass AIOverrides. Seeds, in this order for every pairing: 16001, 16002, 16003, 16004. It writes 144 rows to `.gauntlet/evidence/run016/matrix/rows.jsonl` and `.gauntlet/evidence/run016/matrix/summary.json`, including timeouts and fatal errors, and exits 0 when those 144 rows are written. `node harness/report-run016.mjs` reads those rows and the all-events tables in `.gauntlet/evidence/run012/meta-rank-report.md`, writes `.gauntlet/evidence/run016/placement-report.md`, and exits 0.

Deck refs are relative to `harness/`.

Pool A is every corp with every runner (25 pairings, four seeds):

- `decks/nrdb-il-y-aura-du-sang-3rd-in-worlds-and-039-26-startup.json` — NBN: Reality Plus
- `decks/nrdb-startup-de-profundis-3-0-worlds-2026.json` — Weyland Consortium: Built to Last
- `decks/nrdb-startup-star-power-post-offworld-ban.json` — Nebula Talent Management: Making Stars
- `decks/nrdb-totes-undefeated-leo-2026-worlds-startup-1st-place.json` — LEO Construction: Labor Solutions
- `decks/nrdb-zwift-deth-1st-uppsala-ctk-startup.json` — The Zwicky Group: Invisible Hands
- `decks/nrdb-bag-mags-2026-worlds-startup-1st-place.json` — Magdalene Keino-Chemutai: Cryptarchitect
- `decks/nrdb-just-here-for-the-clean-getaway-zipline-3rd-in-startup.json` — MuslihaT: Multifarious Marketeer
- `decks/nrdb-credit-vampire-1-2-world-startup-2026-10-04.json` — Zahya Sadeghi: Versatile Smuggler
- `decks/nrdb-topan-core-battle-box.json` — Topan: Ormas Leader
- `decks/nrdb-startup-tao.json` — Tāo Salonga: Telepresence Magician

Add-ons, same four seeds: `decks/nrdb-original-nuttah-1st-and-undefeated-sydney-megacity.json` (René "Loup" Arcemont: Party Animal) versus each of the five pool A corps (`rene-vs-corps`); `decks/nrdb-just-make-money-ob-5-3-4th-worlds-showdown.json` (Ob Superheavy Logistics: Extract. Export. Excel.) versus each of the five pool A runners (`ob-vs-runners`); and that Ob deck versus that René deck (`rene-vs-ob`). That is 100 + 20 + 20 + 4 = 144 attempts. The recorded outcomes are 89 `RUNNER_WINS`, 45 `CORP_WINS`, and 10 `NO_WIN_WITHIN_TIMEOUT`. `rene-vs-ob-16004` is stored as `NO_WIN_WITHIN_TIMEOUT`, and `fatalErrors` still holds the NicelyFormatCommand page error about reading `rezzed`.

Seat rates below use only decided games. Timeouts are excluded from the denominator (`n = wins + losses`). A row with a non-empty `fatalErrors` array is excluded from `n` as well, including when its stored outcome is `NO_WIN_WITHIN_TIMEOUT`, and it is not counted as a win. Each pool A archetype uses only `poolA` rows. René uses only `rene-vs-corps` rows. Ob uses only `ob-vs-runners` rows. The Wilson columns are the 95 percent interval with `z = 1.96` from the placement report. Placement scores are the strings parsed from the all-events corp and runner tables. Topan: Ormas Leader has no row in that table and is unranked.

| side | archetype | rank | placement score | wins | losses | n | timeouts | other | seat rate | Wilson lower | Wilson upper |
|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| corp | NBN: Reality Plus | 13 | 111.2 | 11 | 9 | 20 | 0 | 0 | 0.550000 | 0.342082 | 0.741805 |
| corp | Weyland Consortium: Built to Last | 24 | 29.2 | 3 | 15 | 18 | 2 | 0 | 0.166667 | 0.058365 | 0.392225 |
| corp | Nebula Talent Management: Making Stars | 11 | 125 | 13 | 5 | 18 | 2 | 0 | 0.722222 | 0.491269 | 0.875004 |
| corp | LEO Construction: Labor Solutions | 16 | 59.4 | 5 | 14 | 19 | 1 | 0 | 0.263158 | 0.118062 | 0.487920 |
| corp | The Zwicky Group: Invisible Hands | 20 | 41.2 | 5 | 14 | 19 | 1 | 0 | 0.263158 | 0.118062 | 0.487920 |
| corp | Ob Superheavy Logistics: Extract. Export. Excel. | 2 | 240.4 | 6 | 11 | 17 | 3 | 0 | 0.352941 | 0.173095 | 0.587000 |
| runner | Magdalene Keino-Chemutai: Cryptarchitect | 4 | 212.6 | 11 | 8 | 19 | 1 | 0 | 0.578947 | 0.362756 | 0.768584 |
| runner | MuslihaT: Multifarious Marketeer | 5 | 202.8 | 9 | 10 | 19 | 1 | 0 | 0.473684 | 0.273295 | 0.682925 |
| runner | Zahya Sadeghi: Versatile Smuggler | 15 | 61.4 | 15 | 4 | 19 | 1 | 0 | 0.789474 | 0.566653 | 0.914925 |
| runner | Topan: Ormas Leader | unranked | no row | 8 | 9 | 17 | 3 | 0 | 0.470588 | 0.261648 | 0.690371 |
| runner | Tāo Salonga: Telepresence Magician | 28 | 15.2 | 14 | 6 | 20 | 0 | 0 | 0.700000 | 0.481023 | 0.854525 |
| runner | René "Loup" Arcemont: Party Animal | 1 | 400 | 18 | 2 | 20 | 0 | 0 | 0.900000 | 0.698962 | 0.972134 |

The `rene-vs-ob` cell is not folded into those rates. Four games, seeds 16001, 16002, 16003, and 16004: 0 corp wins, 3 runner wins, 1 `NO_WIN_WITHIN_TIMEOUT`. `rene-vs-ob-16004` is stored as `NO_WIN_WITHIN_TIMEOUT`, and `fatalErrors` still holds the NicelyFormatCommand page error about reading `rezzed`. Corp seat rate on the three decided games is 0/3 = 0.000000, Wilson lower 0.000000, upper 0.561506. Runner seat rate is 3/3 = 1.000000, Wilson lower 0.438494, upper 1.000000.

A disagreement would be a ranked same-side pair where the higher placement score has the lower seat rate and the upper bound of that lower rate is less than the lower bound of the higher rate. The report compared René "Loup" Arcemont: Party Animal with each of the four ranked pool A runners, and every pair among those five ranked runners. It compared Ob Superheavy Logistics: Extract. Export. Excel. with each of the five pool A corps, and every pair among those six ranked corps. Topan was not paired. The rene-vs-ob rows were not used. No order disagreement was found.

### 4.14 Approached ice after another ice is trashed (run 017)

Stock in-proc replay, no AIOverrides. Seed 16004, ap 6, speed 0, timeout 120000 ms. Corp deck `harness/decks/nrdb-just-make-money-ob-5-3-4th-worlds-showdown.json`. Runner deck `harness/decks/nrdb-original-nuttah-1st-and-undefeated-sydney-megacity.json`.

Command (from `chiriboga/`):

```
node harness/repro-run017-rezzed.js
```

The observed outcome is `CORP_WINS`. fatalErrors is empty. After `Corp rezzed Stavka`, Flyswatter is trashed, and the approached ice stays Stavka: the log later contains `Encountering Stavka`. The NicelyFormatCommand page error about reading `rezzed` is not in this replay.

### 4.15 Run servers ranked by potential per break cost (run 018)

Stock runner server rank. A higher agenda potential divided by ice-break cost ranks ahead. When those ratios are equal, the higher potential ranks ahead. When potential and bestcost are both equal, the comparator returns 0. It does not divide by zero. A server with bestcost 0 and potential at least 0.5 ranks ahead of a server with a positive finite bestcost. A non-finite or negative bestcost ranks behind those. The 0.5 potential floor and the 0.75 low-potential gate stay. This rank is not a human win rate.

Command (from `chiriboga/`):

```
node harness/repro-run018-server-cost.js
```

The command reads `_compareRunServers` from `ai_runner.js`, evaluates that function, and sorts the pairs with it. It also checks that `serverList.sort` contains `_compareRunServers`. The run printed:

```
{potential:1, bestcost:1} ranks ahead of {potential:2, bestcost:8}: PASS
{potential:3, bestcost:2} ranks ahead of {potential:1, bestcost:2}: PASS
{potential:2, bestcost:4} compares equal to {potential:2, bestcost:4}: PASS
{potential:1, bestcost:0} ranks ahead of {potential:4, bestcost:2}: PASS
serverList.sort contains _compareRunServers: PASS
OK
```

The command exits 0. These four comparison results are the comparator order, not a human win rate.

### 4.16 Stock seat matrix versus placement (run 019)

Stock AI versus stock AI, in process. Same decks and seeds 16001-16004 as run 016, ap 6, speed 0, timeout 120000 ms, no AIOverrides. Seat rates are stock AI seat results and are not human win rates.

The runner ranks servers by potential per break cost, and the approach-ice index follows the same ice when an earlier ice is trashed. Those repairs were already in the tree. This run did not change them.

Commands (from `chiriboga/`):

```
node harness/eval-matrix-run019.mjs
node harness/report-run019.mjs
```

Matrix exit and report exit are both 0. Source: `.gauntlet/evidence/run019/evidence-r1/command-exit-matrix.json` and `command-exit-report.json`.

144 rows. Stored outcomes CORP_WINS 56, RUNNER_WINS 83, NO_WIN_WITHIN_TIMEOUT 5. fatalErrors arrays are empty on every row. Source: `.gauntlet/evidence/run019/evidence-r1/outcome-counts.json`.

The stored outcome differs from the run 016 row with the same id for 45 ids.

No order disagreement was found.

Seat table wins/n from `.gauntlet/evidence/run019/placement-report.md`. For a corp, wins are CORP_WINS. For a runner, wins are RUNNER_WINS.

| side | archetype | wins | n |
|---|---|---:|---:|
| corp | NBN: Reality Plus | 9 | 20 |
| corp | Weyland Consortium: Built to Last | 6 | 19 |
| corp | Nebula Talent Management: Making Stars | 12 | 19 |
| corp | LEO Construction: Labor Solutions | 10 | 20 |
| corp | The Zwicky Group: Invisible Hands | 7 | 20 |
| corp | Ob Superheavy Logistics: Extract. Export. Excel. | 8 | 18 |
| runner | Magdalene Keino-Chemutai: Cryptarchitect | 10 | 20 |
| runner | MuslihaT: Multifarious Marketeer | 11 | 20 |
| runner | Zahya Sadeghi: Versatile Smuggler | 15 | 19 |
| runner | Topan: Ormas Leader | 7 | 19 |
| runner | Tāo Salonga: Telepresence Magician | 11 | 20 |
| runner | René "Loup" Arcemont: Party Animal | 16 | 19 |

### 4.17 Engine-gap tranche: 25 cards from the run-023 deck-completion ranking (run 024)

Fifth expansion tranche. Run-023's card-knowledge catalogue found that only 303 of 584 Standard/Startup-legal
cards had a definition in `sets/*.js`, and that the 25 cards that complete the most 2026 tournament decks differ
from the 25 most-played missing cards (3 cards overlap). This run implements the first list (Option B): 25 cards,
28 print codes, in one NEW file `sets/tranche024.js` (set code `tp24`), plus the alias printing Mystic Maemi 26072
(shares the 27001 body from `sets/newerpacks.js`). `config.js` gains one registry row (+2 lines including the
comment). No engine file, no existing card implementation and no AI file changed.

Cards: Concerto 33075, Nurse Hạnh 36007, Abaasy 33070, Hippocampic Mechanocytes 33085, Direct Access 26028,
S-Dobrado 34012, Debbie "Downtown" Moreira 34019, Digital Rights Management 26117/27006, Hammer 34126,
Scapenet 26104, Time Bomb 33069, Drafter 26101, Alarm Clock 34078, Mercury: Chrome Libertador 34010,
Deep Dive 33022/32003, Backstitching 33019, Cat's Cradle 33016, Earth Station: SEA Headquarters 26120,
Wage Workers 34033, Bass CH1R180G4 26098, Window of Opportunity 34077, Swift 26078/27002, Sell Out 36011,
Underdome Irregulars 36016, Baker 36015.

**Documented approximations** (13 cards; each marked `HONEST APPROXIMATION` in the source):
Concerto, Direct Access and Window of Opportunity pick the server when the event is played; Deep Dive keeps its
8 cards face-up in the R&D array (no set-aside zone) and uses its own access routine; Baker chooses its target at
click time (no "would approach" prompt); Nurse Hạnh sees only the Archives breach flip; Backstitching and Mercury
read the engine's per-subroutine broken flags; Digital Rights Management misses effects that score without the
engine's score check; Hammer enforces its 1-break limit by blocking AI breakers and undoing extra breaks;
Wage Workers infers the action type itself from the commands and events it observes (limits in section 4.32); Time Bomb enforces its install restriction by raising its own install
cost by 1000 while the condition is unmet (a copy in hand can display that cost); Earth Station's one-remote limit
is resolved after the install and its run tax is paid at run start. Alarm Clock temporarily replaces the shared
`phases.runEnds.next` while its run is active and restores it afterwards; no other card writes it.

**Verification.** `node harness/smoke-tranche-run024.js` 29/29. `node harness/smoke-tranche-run0{09,10,13,14}.js`
still pass. Seeded determinism sample (96 games of the frozen run-021 matrix): 94 identical with and without the
tranche; the 2 others hit the 120 s wall-clock cap and match for their first 1,000 / 400 log lines.
`harness/verify-menu.js` green on the regenerated 199-deck menu. Independent public-information audit of 48 AI
items: 0 violations.

**Imports.** 101 decks were predicted to become fully implemented; 89 import as Standard- or Startup-legal
(`.gauntlet/evidence/run024/import-report.json`), 70 of them complete a proof game. The 12 that do not import are
printings outside the engine pools (core-set Sure Gamble 01050, msbp Revolver 32002 / Azef Protocol 32007, one
Hedge Fund printing). 19 Runner proof games stall inside the engine (0 fatal errors); 4 of 21 existing Runner
imports stall the same way. Menu: 199 decks, 127 Standard-legal, 33 Startup-legal. The importer produced an
empty file name for a CJK-titled deck, which broke the menu's format filter; the deck is committed as
`nrdb-musi-cjk-muslihat-99157.*`.

**Support files.** `carddata/tranche024.json` supplies the pack (urbp) for printings 27002 and 27006;
`harness/lib/formats.js` lists it in its catalog merge (same mechanism as `newerpacks.json`).

**Known issues in existing code found on the way (not changed).** `sets/liberation013.js` Threat abilities read
`corp.agendaPoints`/`runner.agendaPoints`, which the engine never sets. Keiko (`sets/tranche014.js`) sets
`memoryBonus` but the engine reads `memoryUnits`. `tools/verify-format-data.mjs` (5 checks) and
`tools/verify-classification.mjs` read only `carddata/carddata.json` and already fail on the previous tree. The
Corp AI rezzes non-ice cards only from title lists in `ai_corp.js`, so it never rezzes Bass CH1R180G4.

### 4.18 Optional-response deadlocks and the run-024 stalls (run 025)

Run 024's proof games showed 19 imported Runner decks that stopped making progress against the stock Gateway Corp.
Run 025 found three separate causes (`.gauntlet/evidence/run025/stall-report.md`):

1. **Deadlock in optional responses (fixed).** `phase.js` calls a non-automatic response's `Enumerate` once to decide
   whether to offer it and again to build the command list. A response whose `Enumerate` consumes a flag or records
   state therefore returns nothing the second time and the phase has no legal command. For `automatic: true`
   responses the engine ignores `Enumerate` and only calls `Resolve`, so every check must live in `Resolve`.
   **Rule for new cards:** `Enumerate` must not change state; a mandatory trigger is `automatic: true` with all checks
   and flags in `Resolve`.
   Fixed cards: Curupira 34015 and Lago Paranoá Shelter 34009 (`sets/liberation013.js`), "Baklan" Bochkin 26017 and
   Raindrops Cut Stone 33068 (`sets/newerpacks.js`); `harness/smoke-tranche-run013.js` updated for the Baklan check.
   Latent and not changed: Boomerang 26075 `responseOnRunEnds` (would deadlock if `availableWhenInactive` were added).
   (Corrected (run 044): fixed. Its `Enumerate` no longer clears a flag, the hook is `availableWhenInactive`, and the
   recursion is offered only for the copy trashed for its ability in a successful run; see "Stability, run-044" at the end.)
2. **Slow games (not fixed).** Four existing decks are not stuck: the Runner AI's run calculator rescans all installed
   cards on every ice-strength lookup (about 85% of CPU in `CalculateAsync -> IceAct -> Strength ->
   ChoicesActiveTriggers`). They finish in 2-230 s on an idle machine; a 120 s wall-clock cap under parallel load
   reports them as timeouts. Use a 300 s cap (or more) for proof games.
3. **Runaway loop (not fixed).** A Funhouse subroutine ("Give the Runner 1 tag unless they pay 4[c]") makes the Runner
   AI's choice fail ("Error executing select choice asynchronously") and re-fire forever (matrix job
   `rene-vs-corps-c03-16001`).

Other defects found, unchanged: Sebastião Souza Pessoa 34066 never offers its cheaper connection install
(`responseOnTakeTags.Enumerate` clears `hadNoTags` on the first call); Lampades 36005 `automaticOnAccess` runs
without its `Enumerate` check and is no longer optional.

Verification: all tranche smokes pass; a 110-deck paired replay (300 s cap) went from 89 to 109 completing decks with
0 newly failing and 0 changed winners; a 96-game seeded sample is byte-identical in 93 games and identical up to the
wall-clock cut-off in the other 3.

### 4.19 Sebastião, Lampades and Valentina (run 026)

Run 025's audit found two cards whose behavior differed from the printed text; run 026 found a third (`.gauntlet/evidence/run026/fix-report.md`).

- **Sebastião Souza Pessoa 34066** (`sets/liberation013.js`): `responseOnTakeTags.Enumerate` cleared `hadNoTags` on its first call, and the engine enumerates more than once per response, so only "Decline" was ever offered and the Runner could never install a connection for 2 less. The flag is now cleared in `Resolve`. The identity's second ability (the Corp must trash a card from HQ to trash a connection with the basic action) remains an approximation: the Corp's basic trash action is hard-coded in `phase.js` and no card-level hook can add a cost to it.
- **Lampades 36005** (`sets/tranche014.js`): was `automaticOnAccess`, which the engine fires without calling `Enumerate`, so every access trashed the card (agendas too) for free and spent a counter. It is now an `abilities[]` entry guarded by `CheckAccessing()` (the Carnivore and Gourmand pattern): optional, charges exactly the printed rez or play cost, never touches agendas, ends the access with `ResolveAccess()`. The trash is preventable. The stealth-only credit restriction is still approximated.
- **Valentina Ferreira Carvalho 34095** (`sets/liberation013.js`): her `automaticOnInstall` opened a DecisionPhase inside the install, which re-ran the install (an endless loop of one click plus 2 credits that the "gain 2 credits" choice paid for). The Threat 3 bonus is now a `responseOnInstall` choice and Threat is computed with `AgendaPoints(player)` (the old check read `corp.agendaPoints`/`runner.agendaPoints`, which the engine never sets). **Rule for new cards:** never open a DecisionPhase from `automaticOnInstall`.

Smokes: `.gauntlet/evidence/run026/repro/smoke_{sebastiao,lampades,valentina}.js`; `harness/smoke-tranche-run013.js` (Valentina) and `harness/smoke-tranche-run014.js` (Lampades) were updated to the new hook shapes.

Verification: all tranche smokes pass; a 110-deck replay at a 300 s cap shows 0 newly failing decks; in 48 seeded Sebastião games against tag-heavy Corp decks the install was taken 102 times (baseline 0) and all 48 finish (baseline 33); a 96-game seeded sample is byte-identical in 93 games and identical up to the wall-clock cut-off in the other 3.

### 4.20 Midnight Sun Booster Pack added to the Standard pool (amendment 027) (replaces the msbp pool statement in section 4.12)

Runs 013 and 014 left the Midnight Sun Booster Pack (`msbp`) out of the Standard pool ("msbp is deliberately outside the format pools"). That reasoning is replaced: Null Signal states that the Uprising and Midnight Sun Booster Packs remain legal in Standard because their cards are secondary printings of cards in the full sets (Rotation FAQ in "Everything You Need To Know About Elevation", 2025-03-28, and the Elevation page FAQ). All seven msbp cards (32001-32007) are reprints of Midnight Sun cards that were already Standard-eligible, so the change adds no card; it only lets the classifier accept the msbp printing code in a decklist.

`tools/format-data.json` (pool additions only): `msbp` added to `formats.standard.implementedPoolCodes` and `formats.standard.sets`; `packCodeReference.packs.msbp` lists `standard`; `banCheckRun027` records the check of the seven codes against the Standard ban list (hits: none). The Startup pool (System Gateway, Elevation, Vantage Point) and all ban lists are unchanged. No engine, set or library file changed.

Effect: five decks that run 024 had to reject because of Revolver 32002 are now Standard-legal and imported (The Vic Crew, Copy of Thieving Hands, Esa strike back, Bubble Boy, Muslihat - Nottingham Continentals; precons 199 -> 204, menu 204 decks, 132 Standard-legal). Classification of every previously existing deck is unchanged. Seven decks stay out because they use Core Set printings of Hedge Fund (01110) and Sure Gamble (01050); those cards are legal through System Gateway reprints but the printing codes are not in the pool (separate item). The 15 seeded proof games for the five new decks all complete.

Evidence: `.gauntlet/evidence/run027/pool-report.md`.

### 4.21 Core Set printing equivalence in the deck importer (run 028)

Decklists that use the Core Set printings of Sure Gamble (NRDB 01050) or Hedge Fund (01110) could not be imported: the engine implements only the System Gateway printings (30030, 30075), and the Core Set entries in `sets/coreset.js` are commented out as duplicates. `tools/import-nrdb-deck/index.mjs` now carries a two-row table `PRINTING_EQUIVALENCE` (01050 -> 30030 Sure Gamble, 01110 -> 30075 Hedge Fund). Before the pool lookup, `normalizePrintings()` rewrites those codes in the card list. A row is used only if its target is a real pool entry with the same title; identity codes are never rewritten. Every rewrite is written to the precon notes, to the deck JSON (`printingNormalizations`) and to the report entry. Decks without such a rewrite produce byte-identical output to before (apart from the date line).

Format semantics did not change: the Core Set pack is still outside both format pools, `tools/format-data.json` and `harness/lib/formats.js` are untouched, and the deck becomes Standard-legal because it is stored with the System Gateway cards, which have identical rules text (cost, text, faction, deck limit compared field by field in `.gauntlet/evidence/run028/importer-checks.json`).

E2: `buildPool()` ignores a definition that sits inside a `//` comment. Before this, 10 Core Set codes that exist only in commented-out blocks (engine ids 1064, 1065, 1067, 1068, 1070, 1092, 1094, 1109, 1110, 1111) counted as implemented, so a decklist using one would have been marked usable while the card does not exist at runtime. No live deck used any of them. `harness/lib/formats.js` `implementedPool()` has the same pattern and was not changed.

Adding another equivalence row needs the same evidence: identical card record, a real engine definition of the target, and a decklist that needs it.

Evidence: `.gauntlet/evidence/run028/equivalence-report.md`.

### 4.22 Implemented-pool parser ignores commented-out definitions in `harness/lib/formats.js` (run 029)
`implementedPool()` in `harness/lib/formats.js` finds card definitions with a regular expression over each `sets/*.js` file, so a block that is commented out (`// coreSet[1110] = {`) counted as an implemented card. It reported 493 codes; ten of them existed only in commented-out Core Set blocks (01064 Rototurret, 01065 Corporate Troubleshooter, 01067 Jinteki: Personal Evolution, 01068 Nisei MK II, 01070 Snare!, 01092 SanSan City Grid, 01094 Hostile Takeover, 01109 PAD Campaign, 01110 Hedge Fund, 01111 Enigma). The same defect had already been fixed in the importer pool (section 4.21, run 028). No live deck used any of the ten codes, and no identity was affected, so nothing changed in play.

The fix is three lines inside the match loop of `implementedPool()`: a match whose line prefix contains `//` is skipped. The pool is now 483 codes and equals the importer pool except for two titles with escaped quotes (35001, 35033: formats.js keeps the backslashes, the importer removes them). Classification of the 210 decks and the generated `menu.html` are unchanged (byte-identical); the callers (importer find-seeding, `tools/meta-rank.mjs`, the run-013/014 evidence scripts) give the same results. Only `harness/lib/formats.js` changed; backup in `.gauntlet/snapshots/run029/pre-apply/`.

The two verify scripts `harness/verify-run013-format.mjs` and `harness/verify-run014-format.mjs` reported FAIL at this time. The cause was not this change: they hard-coded the Standard pool without `msbp` (amendment 027); fixed in run 033 (section 4.26).

Evidence: `.gauntlet/evidence/run029/pool-report.md`.

### 4.23 Missing card art and missing rules text: catalog entries, generated card face, art check (run 030)
Cards whose image file is missing already had a fallback in both views, but it hid the abilities: the classic view drew a plain "Artwork unavailable" placeholder, and the table view drew the title and "Card image unavailable" with rules text read from `table/catalog.json`. The real cause was bigger than the missing images: `table/catalog.json` (749 entries, copied from the carddata files) had no entry for **40 engine-defined cards** (19 Vantage Point, 7 Liberation, 7 newer packs, 6 tranche 024, 1 tranche 014), so their rules text was empty in the table view even when the image existed. Six card codes used by imported decks (36007, 36011, 36015, 36016, 27002, 27006) also had no image file (31 of the 210 decks).

Changes: (1) 40 entries added to `table/catalog.json`, same shape and serialisation, existing entries unchanged; (2) `cardrenderer/cardrenderer.js`: a missing card image now gets a generated face (title, type, faction, costs, rules text from the same catalog), with the generic placeholder as the last resort (methods `CardFaceCatalog` and `ArtworkFace`, used only on the image-error path); (3) `images/27002.jpg`, `27006.jpg`, `32003.jpg` copied from the local art of the same card (26078, 26117, 33022; titles checked equal); no download was made; (4) new `harness/check-card-art.mjs`.

`node harness/check-card-art.mjs [--out file.json]` fails when an engine-defined card has no rules text in the catalog, or when a card code used by a deck has neither an image nor catalog text; it warns for codes that rely on the generated face. Four Vantage Point cards (36007 Nurse Hanh, 36011 Sell Out, 36015 Baker, 36016 Underdome Irregulars) still have no image and show the generated face. Known cosmetic limit at run 030: very long rules text overflowed the bottom of the table-view fallback face in the hand. Since run 027 (round 3) the hand shows a compact fallback instead: the title, a type · cost line and a "No image" note, with no rules text, stepped down in size and then without the note and then without the type line until it fits, so it no longer overflows. The table view's card reader and zone galleries keep the full fallback (title, rules text from `table/catalog.json`, "Card image unavailable"); it was not re-measured in run 027. The headless game harness does not load the renderer, so the renderer change was verified by screenshots of both views and by `verify-menu.js`, and by byte-identity of all engine, AI and set files.

Evidence: `.gauntlet/evidence/run030/art-report.md` (screenshots before and after).

### 4.24 Knowledge base and the AI hook system: audit and an elo pilot (run 031), documentation only
Nothing in `chiriboga/` was changed by this run. It audited how the run-023 knowledge base (`research/netrunner-card-knowledge-2026-10-24/data/`) could reach the AI's card-level hooks, and tested the one data-only route.

Findings: 97 distinct `AI*` hook names appear on cards; 67 are read by engine code, 12 are never read by anything, 18 are helpers that only card code calls. Of the 67 live hooks about 20 are flags or numbers; the rest are functions of game state or hand-written run-calculator models. The knowledge base's per-card advice is free text and its one-hook-per-card suggestion names a dead hook for 24 engine cards. The numeric card rating `elo` exists on 167 of 483 definitions (only System Gateway, System Update 2021, 7 Midnight Sun, 1 Elevation); 64.2% of the card slots in the imported decks are cards without `elo`, and 81.9% of the Corp AI's elo-based tutor/recursion choices picked a card without a rating (the first candidate wins when it has none).

Pilot (fixed design, 1,296 seeded games): giving all 316 unrated definitions a knowledge-base-derived `elo` changed the Corp win rate against the stock Gateway Runner from 25.7% to 26.0% (+0.35 points, one-sided p 0.5; shuffled control 27.1%; Runner 56.3% to 54.9%). By the pre-registered rule the result is neutral and **nothing was applied**. The data-only route is correct but inert; the remaining routes are hand-authored hooks and new AI core code (pair-aware play), which need the AI core files. The fatal-error games seen in the pilot were first attributed to `ai_runner.js`; run 032 showed they come from card-level defects (section 4.25).

Evidence: `.gauntlet/evidence/run031/audit-report.md`, `pilot-report.md` (with the run-032 erratum), `hook-inventory.md`, `coverage-table.md`, `mapping-spec.md`, `pilot-design.json`.

### 4.25 Triage of the remaining gaps G1-G9 (run 032), documentation only
Nothing in `chiriboga/` was changed. All 139 imported decks x seeds 24, 25, 26 were played against the stock Gateway opponent (417 games, 150 s cap): 406 finished, 11 did not. Of the 11, three never finished because of two card-level defects (fixed in run 033) and eight are slow run-calculator games.

Ranking and outcome: **G3** Trickster Taka throws when its credit hook is called without a card; this caused the Funhouse "pay or tag" loop and most fatal games (fixed in 4.26). **G9b** Stavka opened a decision inside an automatic hook (fixed in 4.26). **G1** two verify scripts expected a Standard pool without `msbp` (fixed in 4.26). **G4** Boomerang's "shuffle from heap" recursion is never offered and enabling it as written would deadlock (open; corrected (run 044): fixed in `sets/newerpacks.js`, see "Stability, run-044" at the end). **G8** 258 of 584 catalogue cards are not in the engine; 269 of 550 corpus decks (48.9%) are fully playable, and the next 25 cards by greedy choice would give 308 (56.0%) (open). **G2** the Runner AI run calculator rescans all cards for every strength lookup (about 85% of CPU in `ChoicesActiveTriggers`, `InstalledCards`, `AllCards`); 8 of 417 games hit the cap; the fix is in core files (open, needs permission). **G6** Sebastiao's Corp "trash 1 card from HQ" cost needs a hook in `phase.js` (open, approximation kept). **G5** Lampades stealth credits: the engine has no stealth-credit concept (approximation kept). **G7** closed: none of the other 19 Core Set duplicates has a printing in either format pool, so no further printing-equivalence rows could make a deck legal. G10 and G11 were found afterwards (4.26, 4.27).

Evidence: `.gauntlet/evidence/run032/triage-report.md`.

### 4.26 Card-level fixes: Trickster Taka, Stavka and two verify scripts (run 033)
Changes (backups in `.gauntlet/snapshots/run033/pre-apply/`): (1) `sets/tranche014.js`, Trickster Taka `canUseCredits`: `if (!card) return false;` (`AvailableCredits()` is also called without a card; the TypeError made the AI choice fall back to option 0, which repeated Funhouse "Give the Runner 1 tag unless they pay 4[c]" forever); (2) `sets/newerpacks.js`, Stavka: `automaticOnRez` replaced by an optional `responseOnRez` with an `Enumerate` (the engine itself warned "should not be automatic (it includes a phase change)"); (3) `harness/verify-run013-format.mjs` and `verify-run014-format.mjs`: `"msbp"` added to `EXPECTED_STANDARD`; (4) the Stavka test in `harness/smoke-tranche-run013.js` goes through `responseOnRez`; (5) new `harness/smoke-run033.js` (6 smokes, 24 checks, including the frozen run-021 job `rene-vs-corps-c03-16001`, which went from 64,043 log lines to under 5,000).

Results: new smokes 1 of 6 on the untouched tree, 6 of 6 patched; existing suites 009, 010, 013 (61), 014, 024 identical; the 417-game paired replay had 0 games that finished before failing to finish, 3 games that never finished now finish, 0 fatal errors, 0 winner changes, and 355 of 355 uncapped games in decks without these cards identical. The Corp AI still takes Stavka's +5 option (11 games in both copies). One game in the live subset (`nrdb-our-hand-comrade-startup`, seed 24) sits on the 150 s cap and finished at 149.3 s untouched against a timeout patched; with a 600 s cap both copies give the identical result and fingerprint, so it is a wall-clock artefact of the slow run calculator (G2), not a behaviour change. AI error lines in the replay fell from 934,087 to 4; the four left led to G10 and G11 (4.27).

Evidence: `.gauntlet/evidence/run033/fix-report.md`.

### 4.27 Card-level fixes: WAKE Implant install damage and Off the Books shuffle (run 034)
Changes (backups in `.gauntlet/snapshots/run034/pre-apply/`): (1) `sets/newerpacks.js`, WAKE Implant v2A-JRJ: the install damage moved from `automaticOnInstall` to `responseOnInstall` (the Marrow pattern: `Enumerate` returns `[{}]` for the card itself), because damage can open a prevention window inside an automatic hook; (2) `sets/elevation.js`, Off the Books: two calls of the non-existent `ShuffleArray` replaced by the engine function `Shuffle`, so R&D is shuffled as the card text requires; (3) the WAKE test in `harness/smoke-tranche-run013.js`; (4) new `harness/smoke-run034.js` (6 smokes, 15 checks).

Diagnostic note: `AutomaticTriggers` in `utility.js` compares the phase after each automatic hook with the phase at the start of the loop, so once one hook changes the phase every later hook is also reported as "should not be automatic". The earlier suspects The Class Act and Open Market were such collateral reports (their hooks only set a flag); WAKE Implant was the real offender. The engine check itself was not changed (core file).

Results: the 417-game paired replay had 0 games that finished before failing to finish, 0 fatal errors, 0 winner changes, 360 of 360 uncapped games in decks without these cards identical; a rescan of 139 decks x 3 seeds on a debug engine: messages naming WAKE 34 to 0, the collateral Class Act 7 to 0 and Open Market 5 to 0, `ShuffleArray` 1 to 0; two further messages ("reading 'identifier'", "Invalid choice made") also disappeared. Engine "should not be automatic" warnings fell from 208 to 178; the rest come from other cards (Nuvem SA: Law of the Land `automaticOnPlay`, Valentao `automaticOnRez`, Manuel Lattes de Moura and Sebastiao `automaticOnWouldTrash`, and "Enumerate will be ignored" notes on DreamNet, Illumination, Bling); none stalls a game.

Evidence: `.gauntlet/evidence/run034/fix-report.md`.

### 4.28 Offline manifest and service worker regenerated; freshness check (run 035)
`offline-assets.json` lists every file the deployment serves (the `COPY_ITEMS` list of `tools/vercel-build.sh`) with its size and SHA-256, and `sw.js` carries the manifest revision in its cache name; both are written by `tools/make-offline-assets.mjs`. The offline pack download (`offline-manager.js`) verifies every downloaded file against the manifest. The deploy script regenerates the manifest, but the local tree had not been regenerated since before run 024: the manifest listed 1,667 files against 1,783 in the deploy set, 17 listed files had a different hash (their download fails with "A downloaded file could not be verified"; in a headless browser the download stopped after about a second and could never complete) and 116 files were missing from the manifest (108 imported precons, 6 images, `sets/tranche014.js`, `sets/tranche024.js`).

Regenerated with the repository tool: 1,783 files, 176,767,312 bytes, revision `64ce9e40...`; only `offline-assets.json` and `sw.js` change. Tested in a headless browser: a fresh profile downloads and verifies all 1,783 files in about 7 s and reaches "Ready for offline play"; a profile that already holds an older complete pack gets the new service worker within a few seconds (the old shell cache is dropped, the content cache is kept) and fetches only the changed or new files (131 in the test, about 2.9 MB); an offline game with a tranche-024 card loads the set files from the service worker and reaches its first-turn actions; `harness/verify-pwa.js` passes.

New `harness/check-manifest-fresh.mjs` recomputes the deploy set and the SHA-256 values as the tool does and exits 1 when anything is stale, unlisted or gone, or when `sw.js` does not carry the manifest revision: `node harness/check-manifest-fresh.mjs [--out file.json]`. **Rule: after any edit of a file the game serves (sets, precons, decks, images, the menu, table files, core scripts, `OFFLINE.md`, `README.md`), run `node tools/make-offline-assets.mjs`, then the freshness check.** `public/` (the Vercel output folder) is a stale build from 2026-10-11 and was not touched.

Evidence: `.gauntlet/evidence/run035/manifest-report.md`.

### 4.29 Card art in the deployed build: official prints fetched at build time, art gate (run 036)
`images/` is not in git, so the Vercel build gets only the ~700 images of `images.zip`. Of the 304 card codes used by the 140 decks the menu offers as Standard or Startup legal, 42 had no art there (36 Vantage Point, 3 Uprising booster, 2 Midnight Sun booster, 1 Elevation), which hit 114 decks (112 of 138 Standard, 19 of 33 Startup).

`tools/vercel-build.sh` has a new step 1c, before the pages and the offline manifest are generated, so the manifest includes the new prints. It runs `tools/check-build-art.mjs`; if that passes nothing is downloaded, otherwise it runs `node tools/sync-nrdb.mjs --download` (the existing tool: official prints from `card-images.netrunnerdb.com`, JPEG signature and size checked, written into `images/` only, never committed) and then runs the check again as the gate. If the download fails the build stops with "Set ALLOW_MISSING_ART=1 to build anyway".

New `tools/check-build-art.mjs` classifies every usable precon with `harness/lib/formats.js` (the module the menu uses) and requires `images/<code>.jpg` for every card and the identity of each deck legal in Standard or Startup. It lists the missing cards with their decks and exits 1; `ALLOW_MISSING_ART=1` turns that into a warning and exit 0 (cards then rely on the generated text face of section 4.23); `--out <file>` writes a JSON report. `ALLOW_MISSING_ART=1` never skips the download, only accepts its failure.

Tested in scratch: zip-only images fail the check (42 codes, 114 decks); the sync fetched 116 prints (10.1 MB, 0 failures, 9 s) and the check passed; a full build from the git file set passed in about 15 s; in a browser all 304 codes load and a Startup game with Vantage Point cards boots with an empty `cardRenderer.cardArtFailures`; with NetrunnerDB unreachable the build fails, and with the override it builds with a warning. Not tested from Vercel itself (network access to `netrunnerdb.com` and `card-images.netrunnerdb.com` from the build is assumed). Local `images/` still lacks 4 Vantage Point prints (text face only) until `node tools/sync-nrdb.mjs --download` is run locally.

Evidence: `.gauntlet/evidence/run036/art-report.md`.

### 4.30 Searching your own deck shows the cards to the searcher (run 028 card bugs, B2)
Malapert Data Vault (30066, `sets/systemgateway.js`) builds its choice with `ChoicesArrayCards(corp.RnD.cards, …)`. The engine's `PlayerCanLook` hides every R&D card from the Corp, so the table showed the search as "Facedown card 1..5" and the reader as a card back. Fix in `table/bridge.js` only (the engine's `PlayerCanLook` is unchanged): while the human viewing player owns the current decision (`human()` and the bridge is in options mode), an option card that belongs to that player and lies in that player's own deck (`corp.RnD.cards` for the Corp, `runner.stack` for the Runner) is projected face up (title, code, print, type, rules) in that decision's option labels, option cards and the card reader. The opponent seat, AI vs AI spectate and searches made by the AI never get these projections; the R&D/Stack piles and every other surface stay hidden, and accessed cards keep the existing rules. `table/cgs-view.js` refreshes an open reader from the bridge's `selected` projection, so a searched card's reader stays open. Fixture: `node .gauntlet/run028-card-bugs/tools/fixture-search.cjs --url http://127.0.0.1:<port> --out <dir>` (B2.1 named non-agenda options for the human Corp, B2.2 agendas excluded, B2.3 chosen card to HQ, B2.4 R&D shuffled, B2.5 no seeded title leaks to the human Runner when the AI Corp searches except the card Malapert reveals). Limit: the rule assumes that a decision offering cards in the decider's own deck is a search; the legacy renderer (`GetTitle(card, true)`) still labels such options as hidden.

### 4.31 Ob Superheavy Logistics fires for the human and the AI (run 028 card bugs, B1)
Cause: `Trash()` in `mechanics.js` moves every trashed card to Archives (`MoveCard` clears `rezzed` and the installed location) before it fires `automaticOnTrash` / `responseOnTrash`, so Ob's check "installed and rezzed" (`sets/tranche014.js`, 33057) was always false and nothing happened, for the human Corp and for the AI. The install-phase test (`currentPhase.identifier == "Corp Install"`) could not match either, because the trigger runs in the "Trashed" pseudo-phase.

Engine change (`mechanics.js`, `Trash()` only): right before the cards move, each trashed card gets `card.trashedFrom = {installed, rezzed, faceUp, location, printedRezCost, duringInstall}` (`printedRezCost` = `card.rezCost` or null; `duringInstall` = a card is waiting in `corp/runner.installingCards` or the install trash phase is current when the trash starts). It is recorded for all cards before any of them moves, is valid during that trash's `automaticOnTrash` / `responseOnTrash` window, and is set to null right before `afterTrashing` (a later trash of the same card overwrites it). The `[cards]` parameter of the trash triggers and their order are unchanged; none of the 14 other cards that define `responseOnTrash` / `automaticOnTrash` (Cacophony, Madani, GAMEDRAGON Pro, Detente, Aggressive Trendsetting, Public Access Plaza, Strike Fund, Audrey v2, Steelskin Scarring, René "Loup" Arcemont, Marilyn Campaign, Simulchip, Hostile Architecture, Wage Workers) reads `trashedFrom`.

Ob (33057): eligible trashed cards are the Corp's cards with `trashedFrom.installed && rezzed && !duringInstall` and a printed rez cost of at least 1, during the Corp's turn (approximation of "when you trash", as before), once per turn. The trigger offers one choice per distinct printed rez cost (several trashed cards with different costs: the Corp chooses; a single choice is taken automatically). The human Corp then gets the optional prompt "Use Ob: search R&D for a card with printed rez cost X (card, cost X+1)" or "Pass (no search)" (passing does not use the once-per-turn ability). Searching lists the R&D cards (ice, asset, upgrade) with printed rez cost exactly X, by name (they stay in `corp.RnD.cards` while choosing, so 4.30 applies), plus "Find nothing (decline to install)"; then a server (existing remotes, a new remote, then HQ/R&D/Archives for ice and upgrades); the card is installed through the normal `Install()` flow ignoring all costs and rezzed ignoring all costs by Ob's `responseOnInstall` hook; R&D is shuffled ("Ob Superheavy Logistics: R&D shuffled"). No match: "Ob Superheavy Logistics: no card in R&D with printed rez cost X", R&D shuffled, the ability counts as used (the search happened). The AI Corp is offered Ob only when a match exists, always searches, and keeps `run014CorpInstallAndRez` (most expensive match, automatic destination).

Checks: `node .gauntlet/run028-card-bugs/tools/fixture-ob.cjs --url http://127.0.0.1:<port> --out <dir>` (B1.1 prompt, B1.2 named exact-cost options, B1.3 install + rez with credits unchanged, B1.4 shuffle, B1.5 decline continues, B1.6 no-match log, B1.7 nothing on a second trash, B1.AI the AI Corp installs and rezzes a match) and `node harness/smoke-ob-run028.js` (accept, pass, two trashed cards with different costs, trash during installation, `trashedFrom` cleared). Limits: the "Pass" label is deliberate: `harness/smoke-tranche-run014.js` drives Ob with a human Corp and auto-picks labels such as "Decline"/"Continue", so a "Decline" label there would make that smoke skip the search; the legacy renderer labels the search options "[hidden card]".

Round 4: the human install of the found card is marked as Ob's effect. Right before its `Install()` Ob sets `obInstalling` to the found card; the mark is cleared when Ob's rez hook resolves (its `responseOnInstall` wraps the unchanged `run014InstallRezHook()`, after `automaticOnInstall` has fired), when the install is cancelled, and at the start of each turn. Wage Workers reads it (4.32). The AI path (`run014CorpInstallAndRez`) is unchanged.

### 4.32 Wage Workers counts a human Corp's advances (run 028 card bugs, B3)
Cause: Wage Workers (34033, `sets/tranche024.js`) keys each finished Corp action from what it observes, because the engine counts finished actions (`corp.actionsCompletedThisTurn`, incremented in `corpPostAction.Init`) but records neither their kind nor the ability used. The basic advance resolves as `IncrementPhase(); Advance(card);`, so `automaticOnAdvance` fires inside the post-action window the action has just entered, after the counter already counted it. The card keyed that event as action #counter+1, the action not yet taken. For the AI Corp that window is "Corp 2.2*", which the card ignored, and the advance was keyed from the pending command instead. For a human Corp the engine combines that window with the action phase (`CombineResponseWithPhase`; its identifier becomes "Corp 2.2"). The first advance was ignored (window not combined yet) and later ones were keyed one action too late. Three advances therefore counted as "unknown#1, unknown#2, advance" and no [click] was gained. The draw and gain actions were unaffected because they are keyed when their command finishes.

Fix (Wage Workers block only, no engine change): when an action event fires while the turn step is the post-action window (`phases.corpPostAction`, combined or not), the counter moved during the current command (`run024LastDone` is refreshed after every command) and the executing command names the same action (`advance`, `play`, `trash`), the event is keyed as action #counter. A different command (for example a click ability that advances) keys nothing there, so the next action is never pre-keyed. An event fired before the action's `IncrementPhase` (an install pseudophase) keeps the previous rule (#counter+1). The tally and the [click] gain did not change.

Checks: `node .gauntlet/run028-card-bugs/tools/fixture-wage.cjs --url http://127.0.0.1:<port> --out <dir>` (B3.1 advance x3 then a 4th action, B3.2 draw x3, B3.3 gain x3, B3.4 mixed actions give no click, B3.AI). Also `node harness/smoke-wage-run028.js`: a human Corp entered through Corp 2.1, advance x3 with one target and then the 4th action, advance x3 with a card choice each time, draw x3, mixed advance/gain/advance, and an unrezzed Wage Workers that counts but gains nothing (8 checks; 2/8 on the round-2 card code). `node harness/smoke-tranche-run024.js` still passes 29/29.

Remaining limits: a click ability whose card cannot be identified on the human path counts as a unique action, or as the basic action it performs only when that action's event fires before its `IncrementPhase` (install). A paid ability with a decision, used in a combined (human) post-action window, may mislabel the next action as a click ability (under-count only). An install made right after an action that finished inside a card choice, with no command in between, may be keyed to that earlier action when that action was not identified (under-count only). Actions taken before Wage Workers is rezzed are counted. The [click] is gained only while the card is active. Installs made by other cards' effects (not Ob) inside a combined (human) window can still pre-key the next action as an install, because those cards do not mark their installs (this can over-count; those cards are outside run 028).

Over-count fix (round 4): an install that is a card effect, not the install action being taken, could be counted as an install action. Ob Superheavy Logistics installs inside a trigger window that another command opened (for example a rez whose unique rule trashes the older rezzed copy, or a click or paid ability that trashes a rezzed card), and its prompts are separate `continue` commands. On the human path the counter therefore did not move during the command that fired `automaticOnInstall`, so the event took the #counter+1 rule and pre-keyed the NEXT action as "install". A following basic advance, operation or click ability did not overwrite that key (the advance went to #counter+2). Install, install, [Ob install], advance counted install x3 and gained a [click]. The #counter+1 fall-through applies to any action kind, not to installs only. Fix (Wage Workers and Ob blocks only): Wage Workers ignores `automaticOnInstall` for the card that Ob has marked as installing (`obInstalling`, 4.31), so that install keys nothing. A basic human install still fires with the `n` command of the install pseudophase, so a check on the command name would not work; the basic install path and the AI path are unchanged. Check: `node harness/smoke-ob-wage-run028.js` (human Corp with the Ob identity and a rezzed Wage Workers: install x2 + Bass CH1R180G4 click ability (trash, Ob installs PAD Campaign) + advance; the same with Mavirus' [trash] paid ability; install + install of a 2nd Bass + rez it (the older copy is trashed, Ob installs) + advance by card click, which gives no [click] and an install count of 2; and the control install x3, which gains. 8 checks; 7/8 on the round-3 code, where the unique-rez case gains a [click] with keys 1-3 install, 4 advance). In the round-3 code the click-ability and paid-ability cases did not over-count, because the `trigger` command already pre-keys the next action as a click-ability hint, which Ob's non-authoritative install key could not replace.

## 5. Card images

The repository does not include card art. **The game logic boots without `images/`, but
rendering throws a fatal page error (`drawImage ... broken state`) once card fronts are
drawn, so headless runs need the images present.** Fetch them once from the reference
deployment (NOT committed to git — keep `images/` untracked):

```
curl -o images.zip https://chiriboga.cronbach.com/images/images.zip
python -c "import zipfile; zipfile.ZipFile('images.zip').extractall('images')"
mv images/images/* images/ && rmdir images/images && rm images.zip
```

Result: ~700 files (`images/Corp_back.png`, `images/credit.png`, `NNNNN.jpg` card fronts,
icons). With images present, offline runs issue zero external requests.

## 6. AI tuning hook (surgical changes, 20 lines total)

`ai_corp.js` and `ai_runner.js` each gained one small, clearly-commented addition:

- `CorpAI`/`RunnerAI` constructors merge `window.AIOverrides.corp` / `.runner` onto the
  instance if present (set externally, e.g. by the harness, before `Init()`).
- Three existing local constants were re-expressed as overridable instance properties
  with identical defaults:
  - `corp.purgeVirusMax` (default 10) / `corp.purgeVirusMin` (default 2) — the corp's
    virus-purge propensity (`virus_max`/`virus_min` in the purge logic).
  - `runner.breakerThreatFactor` (default 0.2) — how strongly an installed matching
    icebreaker reduces a known ice's threat score in `_iceThreatScore`.

Run006 added exactly one more surgical change to `ai_runner.js` (17 changed lines, one
guarded call site at the end of the Runner's serverList construction, after
`this.runsReady = true`): when `window.AIOverrides.runner.runSearch` was set before
`Init()` AND `window.RunCortex` is loaded, the constructed `serverList` is handed to
`RunCortex.decide()`, which may return a narrowed candidate list or an empty one (veto
the run). Without the flag the guard short-circuits and the stock heuristic is
byte-identical in behavior (verified: `.gauntlet/evidence/run006/E2-default-off-proof.json`).
No other engine/AI logic was touched. `harness/configs.json` documents the named
configurations built on these tunables.

## 7. Regenerating offline.html

After `engine.php` changes:

```
node tools/make-offline-page.mjs
```

The generator replaces the PHP blocks with their static equivalents (script tags with a
static `?v=offline` cache-buster, the inlined `webfont.php` output) and removes the
Google Analytics block; the dynamic set loader is kept verbatim (it already falls back
to `Date.now()` cache-busters without PHP filemtimes). Run004 additions: the `<title>`
is rebranded to "Netrunner Offline" and `@font-face` blocks (bundled Share Tech
Mono + Orbitron; Exo 2 since run027; relative `fonts/` URLs) are injected into the head — `engine.php`
itself is never edited.

## 8. Criteria met (RunContract C1-C5)

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| C1 offline static | MET | `node harness/run-game.js --pair gateway-starters --ap 6 --out r.json` then check `outcome` is a win and `externalHosts == []` | `.gauntlet/evidence/E1-E2-game-gateway-starters.json` |
| C2 headless completion | MET | same file, `elapsedSeconds` recorded with a win outcome | `.gauntlet/evidence/E1-E2-game-gateway-starters.json` |
| C3 batch | MET | `node harness/batch.js --games 12 --pairs gateway-starters,duel-e2 --workers 2 --ap 6 --out-dir harness/results/batch-001`; `summary.json` shows games>=10, >=2 pairs, completionRate>=0.9 | `.gauntlet/evidence/E3-batch/summary.json` |
| C4 importer | MET | importer command in 4.4; 8/8 imported decks fully implemented (4 corp + 4 runner); each duel pair completes a harness game with no PAGEERROR | `.gauntlet/evidence/E4-import-report.json`, `.gauntlet/evidence/E4-game-duel-e{1..4}.json` |
| C5 tuning | MET | `node harness/tuner.js --configs baseline,jumpy-corp --games-per-config 5 --pair gateway-starters --ap 6 --out-dir harness/results/tune-001`; `tuning-summary.json` `ranked[]` has per-config games and win rates | `.gauntlet/evidence/E5-tuning/tuning-summary.json` |

Run 002 additions (menu, Vercel output directory, deferred fixes):

| Criterion | Status | How to verify |
|---|---|---|
| V1 Vercel output dir | MET | `bash tools/vercel-build.sh` (images/ present locally, download skipped); build assembles `public/` and asserts the central paths; then serve it (`python -m http.server 8131 --bind 127.0.0.1 --directory public`) and run `node harness/run-game.js --pair gateway-starters --ap 6 --server http://127.0.0.1:8131 --out r.json` — the game completes with no failed requests and no 404s in the server log |
| D1 tuner labels | MET | `node harness/tuner.js --configs baseline,jumpy-corp,bold-runner --games-per-config 5 --workers 2 --pair gateway-starters --ap 6 --out-dir harness/results/tune-fix` → 15 distinct `tune-*.json` files, bijective with `gamesIndex` |
| D2 usable flag | MET | `node harness/run-game.js --corp decks/fixtures/unusable-deck.json --runner precon:Gateway Runner` → nonzero exit + clear message; a normal game (`--pair duel-e2 --ap 6`) still completes |
| L1-L3 menu | MET | `node tools/make-menu-page.mjs` (79 decks listed exactly once); `curl http://127.0.0.1:8123/` returns menu.html; `node harness/verify-menu.js --ap 6` passes all flows incl. an AI-vs-AI win |

Run 003 additions (in-process engine):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| P1 in-process single | MET | `node harness/run-game.js --engine inproc --pair gateway-starters --ap 6 --out harness/results/inproc-single.json` → win outcome, `started: true`, `engine: "inproc"`, `mainLoopDelay: 0`; zero puppeteer modules in `require.cache` after `require("harness/lib/inproc.js")` | `.gauntlet/evidence/run003/E1-inproc-single.json`, `.gauntlet/evidence/run003/E1-no-puppeteer-import.txt` |
| P2 in-process batch | MET | `node harness/batch.js --engine inproc --games 12 --pairs gateway-starters,duel-e2 --workers 2 --ap 6 --out-dir harness/results/batch-inproc` → `summary.json`: 12 games, 2 pairs, `completionRate` 1.0 | `.gauntlet/evidence/run003/E2-batch/summary.json` |
| P3 equivalence | MET | same pairs in both engines (12 games each, ap 6): both 100% complete, both sides win in both engines, median log lines 1262 (chrome) vs 1129 (inproc) | `.gauntlet/evidence/run003/E3-comparison.md`, `.gauntlet/evidence/run003/E3-batch-chrome/summary.json` |
| P4 in-process tuner | MET | `node harness/tuner.js --engine inproc --configs baseline,jumpy-corp,bold-runner --games-per-config 5 --pair gateway-starters --ap 6 --out-dir harness/results/tune-inproc` → `tuning-summary.json` `ranked[]` with per-config games/win rates | `.gauntlet/evidence/run003/E4-tuner/tuning-summary.json` |
| P5 speed | MET | ten sequential single-worker in-process games on gateway-starters, ap 6: pooled median 5.5 s (two sequential runs of 10; rerun median ~4 s, first-run median 11 s; range 1-171 s, slow tail = longest games) | `.gauntlet/evidence/run003/E5-timing-sequential/`, `.gauntlet/evidence/run003/E5-timing-rerun/` |
| DOC1 | MET | documented in-process commands of section 4.5 executed verbatim | `.gauntlet/evidence/run003/E6-doc-verification/` |

All harness results default to `harness/results/`; the officially archived evidence for
this run lives under `.gauntlet/evidence/`.

Run 004 additions (cyberpunk retheme, rebrand, bundled fonts):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| V1 brand | MET | `<title>` of `/` and `/offline.html` is "Netrunner Offline"; Orbitron wordmark "NETRUNNER OFFLINE" on the menu; "chiriboga" appears only in the footer upstream credit | `.gauntlet/evidence/run004/round1/menu-1600.png`, `manifest.json` (`documentTitles`), `title-offline.png` |
| V2 menu theme | MET | menu rendered at 1600x1200 + 1280x800 with side-coded/faction-chip decks; hover/focus styled | `.gauntlet/evidence/run004/round1/menu-*.png` |
| V3 game theme | MET | `style.css` rethemed (values only, geometry untouched); zero green `#33ff33`-family remnants in chrome across CSS + engine-JS color literals + icon-tint filters + the five rail sprites' selective pixel recolor (grep + pixel audit; see 3.2 r2/r3 scope); DOM chrome captures on a real board | `.gauntlet/evidence/run004/round1/game-*.png`, `.gauntlet/evidence/run004/round2/` (captures, `color-diff-audit.txt`, `pixel-green-audit.json`), `.gauntlet/evidence/run004/round3/` (captures, `pixel-green-audit.json` with `history_rail` region, `sprite-recolor-audit.json`, `verify-menu.json`), `.gauntlet/run004-tools/retheme-style.py`, `.gauntlet/run004-tools/recolor-history-r3.py`, `.gauntlet/run004-tools/pixel-green-audit-r3.py` |
| V4 fonts offline | MET | `document.fonts.check` true for Share Tech Mono + Orbitron (+ PlayBoldNisei on the game page) with only localhost requests; OFL license files in `fonts/` and shipped to `public/fonts/` | `.gauntlet/evidence/run004/round1/manifest.json` (`fonts`, `externalRequests`) |
| V5 functional | MET | `node harness/verify-menu.js` passes unchanged (incl. a full menu-launched AI-vs-AI game + speed restore); capture guard records zero non-localhost requests | `.gauntlet/evidence/run004/round1/verify-menu.json`, `manifest.json` |
| DOC1 | MET | documented commands executed verbatim (see evidence transcripts) | `.gauntlet/evidence/run004/round1/` |

Note on the board itself: the playing surface (cards, backs, counters, phase timeline
icons) is pixi canvas rendering from upstream `cardrenderer/` and keeps its upstream
colors by design — the retheme covers the DOM chrome around it (headers, phase bar, log
tooltips, prompts, buttons, modals, menus, scrollbars), which is what the game-*.png
captures show.

Run 005 additions (format legality per NSG rules, Standard/Startup filter, NRDB refresh):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| F1 format data | MET | `node tools/verify-format-data.mjs --out ../.gauntlet/evidence/run005/E1-format-data-crosscheck.json` (independent second-path cross-check of every ban-list name->code mapping, implemented flag, pool pack code, source+date; all checks PASS) | `tools/format-data.json`, `.gauntlet/evidence/run005/E1-format-data-crosscheck.json` |
| F2 classification | MET | `node tools/classify-precons.mjs` (84 decks: 14 legal in both formats, 70 casual-only, per-deck reasons); `node tools/verify-classification.mjs` (independent regex-parse + raw-carddata recomputation, 84/84 agree = 100%) | `.gauntlet/evidence/run005/classification-report.json`, `tools/format-report.md`, `.gauntlet/evidence/run005/E2-classification-agreement.json` |
| F3 menu filter + removal | MET | `node harness/verify-menu.js --server http://127.0.0.1:8123 --ap 6 --out ../.gauntlet/evidence/run005/verify-menu.json` — ok:true incl. formatFilter flow (All default, Standard/Startup option sets match classification, Seamless Launch decks hidden, legal AIA launch under Standard, Gateway starters removed from every pool); captures | `.gauntlet/evidence/run005/verify-menu.json`, `.gauntlet/evidence/run005/captures/menu-filter-standard.png`, `menu-filter-startup.png`, `menu-1600.png` |
| F4 NRDB refresh | MET | consolidated import report: 4 scan runs (212 deck pages, 895 API-classified lists, 840 seeded-search candidates, 1105 rejections — essentially all unimplemented-cards), 5 imports, all format-legal (4 runner + 1 corp, both sides), each with a completed harness game | `.gauntlet/evidence/run005/E4-import-report-consolidated.json`, `E4-import-scan-report*.json`, `E4-game-corp-*.json` |
| F5 functional + docs | MET | `node harness/run-game.js --engine inproc --pair gateway-starters --ap 6` completes (explicit file-based run — the Gateway starters are casual-only and out of the menu pools, the harness still runs them for regression); verify-menu green (menu-launched AIA game); documented commands executed verbatim | `.gauntlet/evidence/run005/E5-smoke-gateway-starters-inproc.json`, `.gauntlet/evidence/run005/E5-doc-commands.txt` |

Run 006 additions (Quick Game re-enable, Runner run-decision search + honest A/B):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| Q1 quick game | MET | `node tools/make-menu-page.mjs` (fails loudly on an illegal/missing eligibility name; Quick Game pool "all 2c/4r, standard 2c/4r, startup 2c/4r"); `node harness/verify-menu.js --server http://127.0.0.1:8123 --ap 6 --out ../.gauntlet/evidence/run006/verify-menu.json --shots-dir ../.gauntlet/evidence/run006/captures` — ok:true with quickGame pass in All/Standard/Startup (pool non-empty, drawn decks decoded from the launch URL are pool members and format-legal, launch begins a real game) | `.gauntlet/evidence/run006/verify-menu.json`, `.gauntlet/evidence/run006/captures/quick-{all,standard,startup}-{menu,launched}.png` |
| S1 search module | MET | read `ai_runcortex.js` (k sampled RunCalculator rollouts, configurable weights/k/veto, decision + trace); `git diff cb281a5 -- ai_runner.js` = 17 added lines, one guarded site, default OFF; variant-ON smoke game completes | `.gauntlet/evidence/run006/E2-smoke-runsearch-inproc.json`, `E2-default-off-proof.json` (no overrides: RunCortex loaded, 0 decisions, 0 errors, game completes) |
| S2 evaluation | MET | `node harness/compare.js --arms baseline,runsearch --games 50 --pair ... --pair ... --engine inproc --ap 6 --workers 6 --out-dir ../.gauntlet/evidence/run006/compare-main` (command in 4.6) — 200 games, 100/arm over 2 format-legal pairs, 0 fatal errors, Wilson CIs + delta CIs in `summary.json`; `resultsIndex` bijective with the 200 raw per-game JSONs | `.gauntlet/evidence/run006/compare-main/summary.json` + 200 per-game JSONs |
| S3 honest verdict | MET (null-result) | summary `delta` = +10.0 pp, Newcombe 95% [-9.5, +28.8] pp includes zero -> NOT recommended-default; ships OFF; 323/1014 decisions differed from stock, sampled traces in summary `cortexDecisionExamples` | `.gauntlet/evidence/run006/compare-main/summary.json` |
| DOC1 | MET | documented run006 commands executed verbatim (transcript incl. the compare run's captured stdout) | `.gauntlet/evidence/run006/E5-doc-commands.txt` |

Run 007 additions (learning pipeline: decision logger, self-play learner, honest evaluation — section 4.7):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| L1 data logging | MET | `node harness/validate-decisions.js ../.gauntlet/evidence/run007/decisions/decisions.jsonl --report ../.gauntlet/evidence/run007/decisions/validation-report.json` -> 497 lines / 24 games, 0 malformed, exit 0; logger is flag-gated (`--log-decisions`), off by default; schema documented in `harness/lib/decisionlog.js` header + section 4.7 | `.gauntlet/evidence/run007/decisions/` (decisions.jsonl + 24 per-game JSONs + summary.json + validation-report.json), `.gauntlet/evidence/run007/docs/E5-doc-commands.txt` (live re-log smoke) |
| L2 learner | MET | `node tools/learn-weights.mjs --gens 10 --games 16 --lambda 2 --proc 12 --budget-min 40 --min-gens 3` (curve shows measured fitness per eval, actual N recorded); `node harness/verify-learned-config.js --config-file harness/learned-config.json --out ../.gauntlet/evidence/run007/learn/verify-learned-config.json` -> PASS=true (artifact loads; mechanism-check with a non-default config shows `kRequested` 8 vs 24 in the decision log) | `.gauntlet/evidence/run007/learn/` (learning-curve.json, learner-stdout.json, learner-stderr.log, verify-learned-config.json, verify-mechanism-check.json, attempt1-cancelled/), `harness/learned-config.json` |
| L3 evaluation | MET | 400 games = 2 arms x 2 pairs x 100, interleaved, 4 shards x 4 workers, ap 6 (commands in 4.7); `node harness/compare.js --merge-shards ../.gauntlet/evidence/run007/eval` rebuilds `summary.json` from the 400 raw per-game JSONs; primary delta CI is TRUE Newcombe method-10 (`node harness/compare.js --selftest` pins the math) | `.gauntlet/evidence/run007/eval/` (summary.json + 400 per-game JSONs + 4 shard summaries), `eval-shard{0..3}-stderr.log` |
| L4 honest outcome | MET (null-result) | verdict rule applied: delta -4.5 pp < +5 pp and method-10 95% CI [-14.1, +5.2] includes zero -> NOT recommended-default; analysis of curve trend, explored weights, fitness noise floor (6.25 pp at N=16) and the pooled run006+run007 search effect (~0) in section 4.7 | `.gauntlet/evidence/run007/eval/summary.json` (`delta`, `verdict`), `.gauntlet/evidence/run007/learn/learning-curve.json` |
| DOC1 | MET | documented run007 commands executed verbatim (transcript: live re-runs of the cheap commands + captured stdout of the learner and the shard evaluation) | `.gauntlet/evidence/run007/docs/E5-doc-commands.txt` |

Run 009 additions (card pool expansion: Vantage Point + Liberation; PWA offline — section 3.5):

| Criterion | Status | How to verify | Evidence (raw outputs) |
|---|---|---|---|
| P1 PWA offline | MET | `node harness/serve.js 8199` then `node harness/verify-pwa.js --server http://127.0.0.1:8199 --out ../pwa.json --shot ../pwa.png` -> VERIFY-PWA: PASS (SW active scope `/`, 44 cache entries, offline menu render + offline faceoff "Game begins", zero page errors, zero failed offline requests) | `.gauntlet/evidence/run009/E6-pwa-verify.json`, `E6-pwa-offline-game.png` |
| C1 catalog | MET | `carddata/vantagepoint.json` (66 cards, vp, 36001-36066) + `carddata/liberation.json` (130 cards, tai+rwr, 34001-34130), NRDB sources + fetch date inside; 10-card random sample cross-checked against the saved NRDB card pages (all faithful) | `.gauntlet/evidence/run009/E1-catalog-report.json`, `E1-catalog-crosscheck.txt`, `raw/c1/` |
| C2 prioritization | MET | 100 recent tournament decklists scanned, 6 format-legal targets, union 20 cards (<= ~80, no checkpoint); per-card frequencies ranked | `.gauntlet/evidence/run009/E2-prioritization.json`, `raw/c2/` |
| C3 implementation | MET | `node harness/smoke-tranche-run009.js --out smoke.json` -> 20/20 PASS; `node harness/verify-menu.js` green; registry diff = 2 lines (comment + vp entry) | `.gauntlet/evidence/run009/E3-card-smokes.json`, `E3-verify-menu.json` |
| C4 format + import | MET | `node tools/classify-precons.mjs` (90 decks: 20 standard-legal, 20 startup-legal, 6 new USABLE imports); `node harness/verify-format-data-run009.mjs` -> PASS (58 checks); importer report 6/6 usable | `.gauntlet/evidence/run009/E4-import-report.json`, `E4-classification-report.json`, `E4-format-data-run009-verify.json` |
| C5 playable + docs | MET | three in-proc AIA games pair all six imported decks (ap 6, win outcomes, zero fatal errors); this section + README are the docs | `.gauntlet/evidence/run009/E5-game1-ilyaura-vs-zipline.json`, `E5-game2-deprofundis-vs-bagmags.json`, `E5-game3-richfast-vs-creditvampire.json`, `.gauntlet/evidence/run009/E5-doc-commands.txt` |
| DOC1 | MET | documented run009 commands executed verbatim | `.gauntlet/evidence/run009/E5-doc-commands.txt` |

## 9. Known limitations

- AI-vs-AI games at stock `ap=7` can take longer than 20 minutes; the harness defaults
  to `ap=6` and speed 50 for tractable durations. Some deck matchups may stall; the
  batch summary reports those honestly as `NO_WIN_WITHIN_TIMEOUT`.
- In-process mode (section 4.5) is AI-vs-AI automation only: rendering is stubbed and
  there is no human interaction surface. All in-process games in a batch share one
  Node process, so per-game wall time grows with `--workers` (use `--workers 1` when
  timing matters). Long games show a slow tail (their runner AI run calculations
  dominate; up to ~171 s observed for ~1400+ log-line games on an otherwise busy
  machine), but the median is a few seconds.
- The MENU button's "EXIT TO MAIN MENU" now targets the static root (`./`), which
  `harness/serve.js` and Vercel (rewrite in `vercel.json`) both map to the launcher
  menu (`menu.html`). (It previously pointed at `index.php`, which static hosts cannot
  run.)
- `harness/decks/pairs.json` ship with the four imported duel pairs; add your own
  entries for new decks.
- Format classification is data-driven and only as current as
  `tools/format-data.json`: the implemented pool (SG + Elevation + the run009 Vantage
  Point tranche plus partial older sets) still predates many current-era NRDB decks, so
  NRDB scans still reject most recent lists ("unimplemented-cards"), though run009
  (section 3.5) made the six Worlds 2026 Startup decks fully importable. (Run006 had
  re-enabled Quick Game over the then-empty pool via the `quickGame` eligibility map —
  section 3.1; the 17 upstream `useForQuickGame` decks remain casual-only.) Vantage
  Point and the Liberation cycle now have local catalogs
  (`carddata/vantagepoint.json`, `carddata/liberation.json`, run009 C1) with verified
  pack codes (`vp`, `tai`, `rwr`); the run005 "pack code unverified" caveats and the
  two unresolved ban-list names (Méliès U = 36036, Let Them Dream = 36066) are
  resolved. Run010 (section 3.6) implemented 11 more VP/Elevation cards (the
  fresh-scan tranche), making 4 more recent tournament decks importable (2 Standard,
  2 Startup-only); 40/66 Vantage Point and 128/130 Liberation cards remain
  unimplemented (outside the run009/run010 frequency tranches). The startup agenda
  rule is a classification-time check only (no game-time enforcement, per contract
  non-goals).
- RunCortex (section 4.6) is an experiment, shipped OFF: the search evaluates only the
  run/no-run decision at the Runner's action phase (not the whole turn) and its rollout
  model samples only the corp's assumed credit count over the engine's own
  RunCalculator. Its measured effect (200-game A/B) was a null result: +10 pp runner
  win rate, but the 95% CI includes zero, so it is NOT recommended as a default and
  must be enabled explicitly per game — do not enable it expecting stronger play.
  Run 007 (section 4.7) re-measured it at 400 games with the true method-10 CI
  (-4.5 pp [-14.1, +5.2]); pooled with run006 the search effect is ~0 — it changes
  ~33% of run decisions (incl. ~20% vetoes) without changing win rates. The run-007
  self-play learner (section 4.7) explored the 9-dim config space around the defaults
  for 352 fitness games and found NOTHING better than the default config at its
  measurement resolution (fitness noise floor 6.25 pp at 16 games/eval), so
  `harness/learned-config.json` contains the default config; learning this line
  further needs a lower-variance fitness signal (paired games, larger N) before it
  can resolve effects smaller than ~6 pp.

## 10. Vercel deployment

The tree deploys to Vercel as a static site with one build step that produces a real
output directory (`public/`) — Vercel fails with `Error: No Output Directory named
"public" found` if none is declared/assembled:

- `vercel.json` sets `buildCommand: bash tools/vercel-build.sh`,
  `outputDirectory: public`, and rewrites `/` to `/menu.html` (Vercel has no PHP;
  `index.php` etc. are inert dead weight). `offline.html` stays directly addressable.
- `tools/vercel-build.sh` then:
  1. downloads the card images at build time from chiriboga.cronbach.com and fails
     the build if fewer than ~600 images land — images stay out of git on purpose
     (repo size + the upstream project's own licensing choice; see section 5);
  2. regenerates `offline.html` and `menu.html` from their sources so a deploy never
     ships a stale page;
  3. assembles `public/` from an explicit copy list (`*.html`, `style.css`,
     `manifest.json`, the eleven top-level engine scripts, `jquery/`, `cardrenderer/`,
     `deck/`, `sets/`, `precons/`, `images/`, `fonts/` — the bundled OFL fonts both
     pages load — plus `OFFLINE.md` and `README.md`, the menu footer links to them),
     then deletes any `*.php` that rode along (upstream's inert
     `cardrenderer/webfont.php`) so no PHP ships at any depth;
  4. asserts the central output paths exist (`menu.html`, `offline.html`, `style.css`,
     the two docs, engine scripts, `sets/systemgateway.js`, `precons/`, `images/` with
     ~700 files, and the six `fonts/` files — the three TTFs (Exo 2, Share Tech Mono, Orbitron) and their OFL licenses) and
     that zero `*.php` files remain anywhere under `public/`, failing the build if
     anything central is missing.
- `public/` contains ONLY what the site needs — `harness/`, `tools/`, `carddata/`,
  results and the PHP sources are excluded (the build deletes any nested `*.php` and
  fails if one remains) — and is gitignored build output.

Connect the GitHub repo to a Vercel project with framework preset "Other" and
deploy; no environment variables are needed. Every push to the production branch
redeploys automatically.

The game interface is English in the menu, table, deck information and legacy settings. The entry page declares `lang="en"`; regenerate it from `engine.php` with `node tools/make-offline-page.mjs`. Card names and native English rules are preserved.


The readability candidate uses landscape-only play. Portrait presents a rotate-phone screen with Menu available and preserves the match and seat. It pauses AI playback at a native boundary and restores playback on rotation back only if the rotation paused it. The viewed side's identity, hand, piles and installed zones are near; switching seats also exchanges the rig and server positions. History retains 40 native events behind an optional dialog and rechecks private-card visibility on every render. The table shows a compact current-action cue and larger full-image installed cards. Decisions occupy a measured band below the table; long labels and confirmation text remain available there. New run steps reveal the server and current ICE inside their scroll zones. Portrait closes inspection and deck overlays. See [table controls and checks](table/README.md).


## Standard player areas (16 October 2026)

The original owner playmats define the zone relationships. `table/player-areas.css` owns the new player-area geometry; the existing styles retain the table materials and controls. Corporation ICE use complete native images rotated 90 degrees. Each route shares its centre with its actual HQ identity, R&D pile, Archives pile or remote root. Central upgrades have a separate root strip beside that anchor. The opposite seat reverses both route depth and column order.

Runner Programs, Hardware and Resources occupy three rows, with Programs nearest the opponent. Identity sits beside Programs, Console beside Hardware, and Stack/Heap beside Resources. The support side and row depth reverse together when the viewed seat changes. A Console appears once. Each row can scroll, and Rig opens the complete image gallery. Server growth extends the server scroll zone without changing the table scale. The decision band remains below the table.

The new layout checks are `harness/check-standard-areas.js <origin> <new-output-folder>` and `harness/check-standard-retention.js <origin> <new-output-folder>`. The first uses labelled native fixtures. The second starts a native AI match and compares exact objects, array references, card references and resources across seat and rotation controls. Native action capture remains `harness/capture-readability.js`. These checks do not establish physical touch or complete-game coverage.


## Responsive table allocation (16 October 2026)

The table uses viewport CSS pixels. Server depth and Runner row height respond to the landscape viewport; adding cards or servers extends their local scroll area without reducing existing cards. Public card names, strength and counters appear beside complete card images. Facedown cards use safe backs and a generic caption. The side arrows and row controls retain their native dimensions.

Ordinary actions have a reserved band below the table. Taller choices or confirmations reduce the visible table viewport and allow vertical scrolling within it; they do not scale down cards or controls. All installed rows and server columns remain reachable. Hand opens a gallery of complete card images. A changed run step reveals its server and current ICE in their local scroll areas.

`harness/check-standard-areas.js <origin> <fresh-folder>` records the four landscape sizes in both seats. Its labelled native fixtures include four distinct ICE per server, 24 programs, console and a resource counter. At 667px it clicks ICE arrows through all four native cards, checks each reader, and reaches the last program with actual row arrows before and after resizing. These pointer checks and image bounds are separate from visual review.

`harness/check-active-retention.js <origin> <fresh-folder> [seconds]` starts an actual AI match from Menu. It waits for installed cards and an active run, clicks Pause, waits for the next blocked native Main boundary, then records exact object references, array references, ordered members, resources, phase and run fields. It uses actual seat and orientation controls and compares before Resume. A bounded failure to reach that state is reported as missing evidence. A null current ICE is recorded as null. The script never installs a board fixture. `harness/check-standard-retention.js` remains an opening-state check.

The current service-worker shell family is `nroff-complete-offline-v15`; the exact revision is in `sw.js`. Local Chrome captures do not establish physical touch usability, complete offline card-image availability, complete-game coverage or independent acceptance.


## Belief audit (23 October 2026)

`harness/audit-belief.mjs`, `harness/audit-parity.mjs` and `harness/audit-tally.mjs` are the run-020 measurement rig (netoffline-run-020, contract `.gauntlet/contract-run020.yaml`). The belief audit plays the stock AI (RunCortex OFF, CorpDecept OFF, learned evaluator OFF, no AIOverrides) on the run-016/019 144-seed tournament matrix with zero behavior change, and at every runner run-server assessment records the stock's own score input for the hidden component (prior_value), the true hidden cards read read-only from engine state, and the score recomputed with the stock's own formulas as if the hidden component were known (truth_value). Each instance carries the stock's actual choice, the recomputed argmax under truth substitution, and the assumption-false / choice-flip booleans, classified into the pre-registered classes A1 (R&D top card), A2 (HQ corp-hand agendas), A3 (remote-server contents), B (unknown ice identity) and C (corp rez/ambush). Same-seed games are identical to plain stock.

All commands run verbatim from the repo root (chiriboga/).

```
node harness/audit-belief.mjs               # full 144-game matrix; resumes where rows.jsonl stops
node harness/audit-belief.mjs --fresh       # truncate outputs first, then run all 144 games
node harness/audit-belief.mjs --limit 3     # first 3 pending jobs only (smoke)
node harness/audit-belief.mjs --job <id>    # one matrix job by id (comma-separated list allowed)
node harness/audit-parity.mjs               # per-seed winners vs recorded run-019 rows -> parity.json
node harness/audit-tally.mjs                # re-apply the pre-registered C4 rule -> verdict.json, audit-report.md
```

The full matrix takes roughly 35-40 minutes (per-game wall-clock cap 120000 ms; near-limit games dominate). Outputs live under `.gauntlet/evidence/run020/`: `rows.jsonl` (stock outcomes plus instrumentation summary) and `instances.jsonl` (one JSON object per classified instance) from `audit-belief.mjs`, `parity.json` from `audit-parity.mjs`, and `verdict.json` plus `audit-report.md` from `audit-tally.mjs`.

Result on the full matrix (evidence `.gauntlet/evidence/run020/`, generated 2026-10-23): parity verdict FULL_MATCH — 144/144 per-seed winners identical to the recorded run-019 rows, totals 83 RUNNER_WINS / 56 CORP_WINS / 5 NO_WIN_WITHIN_TIMEOUT. The C4 verdict is NON-EMPTY: A2 (HQ corp-hand agenda assumption) qualifies — assumption-false rate 0.573, choice-flip rate 0.195, 910 flips in the dominant direction "underestimated" (ratio 2.535); A3 (remote-server content assumption) qualifies — assumption-false rate 0.945, choice-flip rate 0.199, 650 flips in the dominant direction "underestimated" (ratio 5.285). A1 (false rate 1, flip rate 0.163, dominant ratio 1.381 < 2:1) and B (false rate 0.488, flip rate 0.079 < 0.10) do not qualify. C is NOT_EXTRACTABLE (740 corp rez decision sites observed read-only; the stock encodes no checkable hidden-information expectation in rez/ambush decisions). Per the contract's human checkpoint, turning A2/A3 into a concrete heuristic rule requires user approval and is a separate run with this rig as its acceptance instrument.

## Measurement rig (23 October 2026)

`harness/eval-matrix-run021.mjs`, `harness/rig-analyze.mjs` and `harness/formats-crosscheck.mjs` are the netoffline-run-021 measurement rig (contract `.gauntlet/contract-run021.yaml`, amended by `.gauntlet/contract-run021-v2.yaml`). It plays the stock AI (no AIOverrides, ap 6, speed 0, per-game cap 120000 ms) on the run-016/019 tournament seat matrix extended to 16 seeds per cell: the same 36 deck-pairing cells, seeds 16001-16016, 576 games total. Same-seed games reproduce the recorded run-019 baseline exactly. `harness/rig-protocol.md` is the FROZEN acceptance standard: every future AI-behavior change is accepted only through this rig, with paired same-seed comparison, corp/runner roles reported separately, Holm-Bonferroni multiplicity control for scan claims (distinct from power), a declared minimum detectable difference, a deck holdout rule, decision-level evidence required alongside outcomes, and regression parity against the run-016/019/020 baselines.

All commands run verbatim from the repo root (chiriboga/).

```
node harness/eval-matrix-run021.mjs                # full 576-game matrix; resumes where rows.jsonl stops (never truncates)
node harness/eval-matrix-run021.mjs --smoke        # 2 full cells x 16 seeds (32 games) into .gauntlet/evidence/run021/smoke/
node harness/rig-analyze.mjs --rows <path.jsonl>   # parity + separation + power report next to the rows (default: the full matrix)
node harness/formats-crosscheck.mjs [--out <path>] # read-only Startup 26.09 legality cross-check (KB vs tools/format-data.json)
```

The full matrix takes roughly 2-2.5 hours wall clock (per-game cap 120000 ms; a handful of timeout-boundary games dominate). Outputs live next to the rows: `rig-report.md`, `separation.json` and `power.json`; the cross-check writes `formats-crosscheck.json` under `.gauntlet/evidence/run021/`.

Result on the full matrix (evidence `.gauntlet/evidence/run021/matrix/`, generated 2026-10-23): C1 parity PASS_WITH_DOCUMENTED_EXCLUSION — 144/144 shared-seed winners identical to the recorded run-019 rows, totals 83 RUNNER_WINS / 56 CORP_WINS / 5 NO_WIN_WITHIN_TIMEOUT, exactly one fatal row in all 576 games (`ob-vs-runners-r05-16016`, EnumeratePhase undefined-option-element crash at command.js:606, run-015 defect class) classified EXCLUDED_KNOWN_DEFECT on a non-shared seed, excluded from seat-rate denominators and registered as an engine-repair candidate. C2: the scan observes 13 placement inversions, 0 SEPARATED / 13 NOT_SEPARATED at n=16 under Holm-Bonferroni (alpha 0.05) across the 36 cells; at the archetype-pair level (Newcombe method 10, n up to 80 per archetype) 3 of 10 same-side inversions separate, including Ob below Nebula and Zahya above Magdalene/MuslihaT. C3 minimum paired detectable difference (exact sign test, alpha 0.05, power 0.80): 70.7 pp at n=16 and 52.7 pp at n=32 in the all-discordant best case, falling to 47.5 pp / 35.0 pp at discordance 0.5. C5 formats cross-check: RESOLVED_DELTAS — the Startup 26.09 profiles agree (pool, bans, agenda rule, Reality Plus unban confirmed legal); the René and Ob fixture decks are not Startup-legal under 26.09 and are documented frozen-fixture deltas.

## Table clarity, run-029 round 1 (26 October 2026)

Gauntlet run `netoffline-run-029-table-clarity` (contract `.gauntlet/contract-run029-table-clarity.yaml`), criterion C2-remote-roots. Defect: the table drew only `roots[0]` of each server, so a remote with an asset and two upgrades showed one card, and an upgrade in HQ replaced the HQ identity card. `table/cgs-scene.js` now draws every root card as its own board object: the main card (remote: asset/agenda; central: identity / top pile card) keeps its place and every other root card is an upright, non-overlapping spine to its left with its own tap target (at least 24 x 24 CSS px) that opens the reader, plus a ×N root-count chip on the main card (details in `table/README.md`, Dense zones). Presentation only: no engine, bridge or command change. Evidence: `.gauntlet/run029-table-clarity/tools/fixture-roots.cjs` (C2.1 / C2.2 PASS at 667x375, 844x390, 1366x768 from both seats); run-026 hud/motion probes, run-025 audit and `harness/verify-action-visibility.cjs` unchanged.

## Table clarity, run-029 round 2 (26 October 2026)

Criterion C2-remote-roots, critic round 1 gap. The round-1 ×N chip was drawn in fixed SVG units (about 6 CSS px text at 667x375 and 844x390), counted the central's pile card (HQ with one upgrade read "×2"), and a lone HQ upgrade became a 10-14 px spine beside the identity. `table/cgs-scene.js` now: draws a central's installed upgrade as the full-size main card again (pile representation only when the central root is empty; the identity stays at Corp ID); draws the root badge as a px()-scaled pill (text at least 12 CSS px, contrast about 17:1) whose number is the engine root length, "×N" on remotes with N of 2 or more and "+N" on centrals with N upgrades; widens spines to the larger of 18 screen px and 28% of a root card (floor 16 px, 3 px gaps); and lets a remote place its spines in the right-hand room when that room is larger and the right neighbour holds at most one card. Details in `table/README.md` (Dense zones, Root stacks). Presentation only: no engine, bridge or command change. Evidence: `.gauntlet/run029-table-clarity/tools/fixture-roots.cjs` (C2.1 / C2.2 PASS, both seats, 3 viewports); badge probe `.gauntlet/run029-table-clarity/scratch/b2-badge.cjs` (12.0 CSS px at 667x375 and 844x390, 12.8 at 1366x768, contrast 17.6:1, number = engine root length); run-026 hud/motion probes, run-025 audit and `harness/verify-action-visibility.cjs` pass as before. Limitation: with three or more extra cards in a crowded phone row the spines keep their 16 px floor and can come closer than 8 px to a neighbour column (not exercised by the fixtures). (Superseded in round 3: spines now fan to stay inside the room.)

## Table clarity, run-029 round 3 (26 October 2026)

Criterion DOC-run029-table / C2-remote-roots, critic round 2 gap: README said spines never enter the neighbour's room, but with the main card at its 70% floor and spines at their 16 px floor nothing kept them there (a crowded probe on the round-2 build measured spines intersecting neighbour count captions, chips and root cards, with gaps down to 1.1 CSS px). `table/cgs-scene.js` now measures the neighbour's count caption and server chip text instead of estimating them, and lets spines overlap as a fan when the room is still short, re-clamped against the drawn main card; spine tap areas start at the spine's outer edge and run inward. Presentation only: no engine, bridge or command change. The rule, the same text as `table/README.md` (Dense zones, Root stacks): Room rule: spines stand in the room between the stack's own root footprint and the room limit, which is the neighbour column's outermost object on that side (its root card edge, its count caption or its server chip, whichever reaches furthest) plus 8 screen px, or 8 screen px before the screen edge for the outermost column; the room is capped at 1.1 root-card widths and is never negative (a column's own root footprint always belongs to it). When the room is short, the main card shrinks first (to no less than 70%, keeping its outer edge), then spines narrow (to no less than 16 screen px), then spines overlap as a fan (inner spines drawn over outer ones, the main card on top) at whatever pitch fits, so the outer edge of the stack, tap areas included, never passes the room limit. Each spine keeps a tap area of at least 24 x 24 CSS px that starts at its outer edge and runs inward under the next spine or the main card (tap areas overlap only inside the same stack); its exposed strip, one pitch wide, is where a tap reaches it (at least 5 CSS px in the crowded probe). The main card keeps the column's single-card root position, so its clearance from a neighbour is that of the single-card layout (run-026 rule; as little as 5.8 CSS px to a wide neighbour chip in the crowded probe). Verification scope: the room rule is measured by the crowded probe `.gauntlet/run029-table-clarity/scratch/b3-crowd.cjs` (HQ with 3 upgrades; a mid-row remote with an asset and 2 upgrades between two remotes, its right neighbour holding 2 cards; a remote with an asset and 4 upgrades; 667x375 and 844x390, both seats): no stack object intersects another server's card, empty slot, count caption or server chip, and every spine keeps at least 8 CSS px from them. fixture-roots covers the 3-card remote and the HQ upgrade at 3 viewports; other states are covered only by the run-026 hud and run-025 audit gates.

## Table clarity, run-029 round 4 (27 October 2026)

Criterion C2-remote-roots, critic round 3 gap: the root badge was not drawn on a shrunk main card. Its corner was found by sampling the card outline at a height below the card (the pill plus its 4 px inset, about 19.6 CSS px, is taller than a main card shrunk to about 18 CSS px on the Runner's far side), which gave no x position, so the badge text landed at about x = -6.5 CSS px, off screen (frozen crowd probe clause X4 failed in 7 of 16 rows: HQ "+3", Crowd B "×3" and Crowd D "×5" from the Runner seat at 667x375 and 844x390). `table/cgs-scene.js` now places the badge from the drawn card outline only, and draws it outside the card's art clip after the rim and edges. Presentation only: no engine, bridge or command change. The placement, the same text as `table/README.md` (Dense zones, Root stacks, Root badge): Badge placement: the badge is placed from the main card's drawn (perspective) outline, never from a width sampled outside it. On a main card with room for it, the pill sits in the top-right corner, 4 screen px inside the top and right edges. On a small or shrunk main card (for example a main card shrunk toward its 70% floor in a crowded row, about 18 CSS px tall on the Runner's far side at 667x375 and 844x390), the pill shrinks to no less than 1.1 times its text size, keeps 1 screen px clear of the card's top and bottom edges and is centred on the card's height; when the card is too narrow for 4 screen px on each side of the pill, the pill is centred on the card's width. The text stays at least 12 CSS px. The badge is kept 2 screen px inside the board and is drawn over the main card's rim and edges, so only the main card's own transparent tap area lies above it (a tap on the badge opens the main card's reader). In the crowded probe the pill lies inside the main card's outline in every case (at least 1 CSS px from its top and bottom edges and 2.5 CSS px from its sides), so the badge adds nothing outside the stack. On such a small card the badge covers most of the main card's face and can hide counter chips on it; a badge wider than its main card (not seen in the probes) is centred on the card and sticks out at its sides. Verification scope: the frozen crowd probe `.gauntlet/run029-table-clarity/tools/crowd-probe.cjs`, clause X4 (HQ with 3 upgrades; a mid-row remote with 3 cards and its right neighbour with 2; a remote with 5 cards; both seats; 667x375 and 844x390), checks that the badge text is fully on screen, is the topmost painted element at its centre, is at least 11 CSS px (12 measured) with at least 4.5:1 contrast against its pill (17.6:1 measured), and shows the engine root length; fixture-roots covers the 3-card remote and the HQ upgrade at 3 viewports; other states are covered only by the run-026 hud and run-025 audit gates.

## Table clarity, run-029 round 5 (27 October 2026)

Criterion C7-public-counters (contract v2; player feedback: an unrezzed Clearinghouse with 6 advancement counters went unnoticed). Gap at round 4: `table/bridge.js` returned only key, title "Facedown card" and hosted cards for a card the viewer may not look at, so the Runner seat and the spectate Runner view showed no advancement chip on unrezzed Corp cards (frozen fixture `fixture-counters.cjs` on r03: C7.1 FAIL, Runner and spectate Runner 1 of 3 counters); chips used a fixed SVG font size (advancement text 6.7 CSS px at 667x375, 7.0 at 844x390, 13.7 at 1366x768; C7.2 FAIL) and the credits fill gave 4.11:1 (advancement 4.71:1); and on a main card shrunk in a crowded row the root badge was drawn over its chips. Changes: `table/bridge.js` adds the public counter fields (`counters`, `counterBadges`, the same shape as the face-up projection, from one shared function) to the facedown projection and nothing else; `table/cgs-scene.js` draws counter chips for facedown backs as well as faces, sizes them from the screen scale, darkens the advancement, credits and virus fills, places them as described below, lays out a crowded stack's chips and root badge as one column, and chooses a counter-carrying card as the main card of a stack with no face-up asset or agenda. No new action, no change to what any command submits, no engine change. The placement, the same text as `table/README.md` (Play, Counters):

Counters (run-029 round 5): counters on an installed card (advancement, credits, virus, power, agenda) are public information, so every viewer sees them on the board: the Corp, the Runner and both AI vs AI spectate views, on face-up cards and on facedown (unrezzed) Corp cards alike. Who sees what: a facedown card's projection (`bridge.js`) carries its key, its public counters and its hosted cards, and nothing else about its face (no title, code, image, type, cost or rules); its reader shows the counters under the card back. Each nonzero counter is a chip with a type glyph and the value (A advancement, Ⓒ credits, V virus, P power, ♜ agenda) in near-white text at least 12 CSS px (scaled with the screen like the root badge: 12 px at 667x375 and 844x390, 13.7 px at 1366x768) on a dark fill of its own colour (contrast 5.9:1 to 6.6:1; advancement 6.4:1, credits 6.6:1). Counter placement: a card's chips sit in its lower-left corner, 4 screen px inside its edges, on the near side's roots and ICE and on rig cards, and in its upper-left corner on the far side's roots and ICE (the edge whose neighbours, the server plaque or the root, are drawn before the card, so a later card never covers them); they wrap into rows inside the card width, and a block taller than the card spills out past that edge. A spine carries no chips (run-038 round 1: its chips sit in the stack bar). Chips are drawn over the card's rim, so only the card's own transparent tap area lies above them; the chip text takes no pointer events, so only the pill is a hit target. Stack column (since run-038 round 1 only on a single root card whose chips would meet its badge, such as a central's single upgrade; a root with two or more cards uses the stack bar instead, see Stack bar, run-038 round 1): on a stack's main card, when its chips would meet the root badge (a main card in a stack of several root cards on phones) or when a spine of a fanned stack (spines overlapping because the room is short) carries counters, the stack's chips and the root badge form one column on the main card: every chip pill and the badge pill shrink to 1.1 times their text size (text size unchanged); the chips of the whole stack, ordered by their cards' positions and each as near its own card as fits, are packed into rows inside the stack's footprint on the plaque side of the main card's horizontal centre line (1.5 screen px clear of it), and the badge sits across that line on the other side, 1 screen px from the chips, so no chip covers the badge or another chip and every spine keeps its tap point on that centre line unless the column had to move off the server name (see Server name below) (a tap on a column chip that lies outside its own card's tap area, for example a fanned spine's chip over a spine strip or the main card, reaches no card; the rest of each spine's exposed strip still opens its reader). A fanned spine's chips are drawn in a second group carrying that spine's `data-card-object` key, after the main card, so neither the next spine nor the main card covers them; the motion layer and the reader use the card's first group. Server name (corrected in run-029 round 7): on a root card no counter chip, root badge or rez cost pill covers the server name. A chip block or stack column that would reach into the name's text box is moved toward the card, badge included, until it reaches at most 1.5 screen px into the server chip plate (run-038 round 1; run-029 measured this from the name's text box); in a crowded stack that move can carry the column across the main card's centre line and past the card's far edge onto the first ICE, so a root's stack column is drawn after the server's ICE (in a group carrying the main card's `data-card-object` key) and stays topmost there. The round-5 text said the pills reach up to 2 CSS px into the bottom of the name; that was measured on the main card's chips only and was false for a spine's second-row chip, which covered "Cro" of "Crowd B" at 667x375. In a stack with no face-up asset or agenda (for example the Runner's view of a facedown remote), the main card is the first root card that carries counters, so an advanced facedown card stays full size. Verification scope: the frozen fixture `.gauntlet/run029-table-clarity/tools/fixture-counters.cjs` (an unrezzed asset with 6 advancements and unrezzed ICE with 2 in one remote, a rezzed asset with 9 credits in another; Runner seat, Corp seat and both spectate views; 667x375, 844x390, 1366x768) checks each chip's number, on-screen rect, top painting, text size and contrast, and that nothing identifies the facedown cards; the scratch probe `.gauntlet/run029-table-clarity/scratch/b5-crowd-counters.cjs` (the frozen crowded probe's stacks with advancement counters on the main card and on one spine, face-up and facedown, each with one ICE; both seats; 667x375 and 844x390) checks that every main and spine chip shows the engine value, is the topmost painted element at its text centre and does not intersect the badge pill, and that, with chip and badge pills counted as part of their cards, the frozen clauses X0-X4 still pass; virus, power and agenda chips share the same code but no fixture seeds them.

`table/README.md` also changes in three places: the Play paragraph says facedown backs show counter chips; Root stacks names the main-card rule for stacks without a face-up asset or agenda; and Badge placement replaces the round-4 limitation "can hide counter chips on it" with a pointer to the stack column (no chip is hidden under the badge), and states that the 1 screen px clearance above and below a shrunk badge holds only while the card is at least about 15.2 CSS px tall. The round-4 paragraph above is kept as written at round 4.

Fixtures and commands (live tree served by `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9430 .gauntlet/run029-table-clarity/scratch/empty-b5`, outputs under `.gauntlet/run029-table-clarity/scratch/b5-*`): `fixture-counters.cjs` C7.1-C7.4 PASS (36 of 36 chips visible for 4 viewers x 3 viewports; advancement and credits text 12 CSS px at 667x375 and 844x390, 13.7 at 1366x768; contrast 6.41:1 advancement, 6.60:1 credits; no title, code or face image of the facedown cards reaches the Runner); frozen `crowd-probe.cjs` X0-X4 PASS with the same gaps and tap strips as round 4; scratch `b5-crowd-counters.cjs` X0-X4 PASS with chip and badge pills counted, 56 of 56 chips (28 on spines) show the engine value, are topmost at their text centre and miss the badge pill; `fixture-roots.cjs` PASS; run-028 `fixture-search.cjs` B2.1-B2.5 PASS; run-026 `hud-probe.cjs` G4 no new collision category and G5 at 1366x768 median 88.6 (state 05) and 67.8 (state 09), fraction 1.0; run-026 `motion-probe.cjs` 4 of 4; run-025 `audit-ui.cjs` H2 text 0, H3 0, H4 0, H5 0; `harness/verify-action-visibility.cjs` exit 0 (100 of 100 expected actions visible and hit). Remaining limits: virus, power and agenda chips and a card with two or more counter types are not seeded by any fixture; in the stack column the chip pills on the Runner's far side reach up to 2 CSS px into the server name's text box (measured on the main card's chips only; false for a spine's second-row chip - corrected in round 7, see Server name); a tap on a column chip outside its own card's tap area reaches no card; a fanned spine's chips live in a second group with the spine's key (the frozen fixtures accept this; other tools that expect exactly one group per key would see two).

## Table clarity, run-029 round 6 (27 October 2026)

Criterion C1-unrezzed (contract v2, style B by user decision; player feedback item 4: from the Corp seat the Corp's own unrezzed cards looked the same as rezzed ones). Gap at round 5: `table/bridge.js` projected the Corp's own installed cards face up with no rez state, and `table/cgs-scene.js` drew every face-up card with its art, so an unrezzed ICE, asset, upgrade or facedown agenda was indistinguishable from a rezzed one and showed no rez cost (frozen fixture `fixture-unrezzed.cjs` on r04: C1.1 FAIL, C1.2 FAIL, C1.4 FAIL; C1.3, C1.5, C1.6 PASS). Changes: `table/bridge.js` adds `unrezzed: true` and `rezCost` (when the card has one) to the Corp seat's projection of the Corp's own installed facedown cards and nothing to any other projection; `table/cgs-scene.js` draws such a card as the facedown back with a corner peek of its face and a rez-cost pill. No new action, no change to what any command submits, no engine change. The look, the same text as `table/README.md` (Play, Unrezzed look):

Unrezzed look (run-029 round 6): from the Corp seat (and in the AI vs AI spectate Corp view), each of the Corp's own installed cards that is still facedown - unrezzed ICE, asset or upgrade, or a facedown agenda in a root - is drawn as the same facedown back the Runner sees (dark back, grid, emblem, brighter cyan rim), with its face peeking through the top-left corner: a window 64% x 50% of the card (ICE, drawn sideways: 56% x 60%; a spine: its full width x 45%) holding the card's art crop, with a folded bottom-right corner and a light-cyan inner edge. Its printed rez cost sits in a light pill with dark text (12 CSS px at every viewport, contrast 15.9:1) in the card's top-right corner, 2 screen px inside the drawn edge (centred on a spine or on a card too narrow for it); an agenda has no rez cost and gets no pill. The pill moves down below the root badge, a counter chip or the hosted-count chip when it would meet one, and is drawn under them, so the badge and the chips stay topmost; it takes no pointer events, so a tap anywhere on the card opens its reader (full face, and Rez when it is legal). The card's accessible name reads "title · unrezzed · rez cost N". Once the card is rezzed (or turned face up) the next render draws the normal face. Who sees what: `bridge.js` adds `unrezzed: true` and `rezCost` only to the Corp seat's projection of the Corp's own installed facedown cards (the Corp player and the spectate Corp view, which already see those faces); the Runner seat and the spectate Runner view keep the plain facedown back and the facedown projection (key, public counters and hosted cards only), with nothing added. Verification scope: the frozen fixture `.gauntlet/run029-table-clarity/tools/fixture-unrezzed.cjs` (Corp seat: rezzed and unrezzed ICE on HQ, a rezzed asset, an unrezzed asset, an unrezzed upgrade and a facedown agenda, each alone in its server; 844x390 and 667x375; one card rezzed through its reader; Runner seat and spectate leak checks) checks the back plus face image, the cost text, the normal look one render after the rez, and that no title or face image reaches the Runner or the spectate Runner view. Limits: in a fanned stack (spines overlapping) the next spine can cover a spine's peek and cost pill, as it covers its art; the round-6 limit that a crowded main card's cost pill could lie under the stack column's chips or badge no longer holds (corrected in run-038 round 1: a multi-card root's chips and badge sit in the stack bar outside the cards); no fixture seeds an unrezzed card inside a multi-card stack.

`table/README.md` also changes in one more place: Files and boundaries names the two new projection fields on the `bridge.js` line. The round-5 paragraph above is kept as written at round 5.

Fixtures and commands (live tree served by `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9440 .gauntlet/run029-table-clarity/scratch/empty-b6`, outputs under `.gauntlet/run029-table-clarity/scratch/b6-*`): `fixture-unrezzed.cjs` C1.1-C1.6 PASS (4 of 4 own unrezzed cards carry the back plus face peek and 3 of 3 with a rez cost show it, at 844x390 and 667x375; 0 of 2 rezzed cards carry either; Regolith Mining License rezzed through its reader switches to the normal face one render later; Runner seat and spectate Runner view: no title, face image or new projection field for the unrezzed Corp cards, which stay plain backs); `fixture-counters.cjs` C7.1-C7.4 PASS (36 of 36 chips visible, text 12 CSS px at 667x375 and 844x390 and 13.7 at 1366x768, 6.41:1 advancement, 6.60:1 credits, topmost over the new look on the unrezzed Clearinghouse and Kessleroid from the Corp seat); `crowd-probe.cjs` X0-X4 PASS (minimum spine gap 8 CSS px and minimum exposed tap strip 5 CSS px, as in round 5); scratch `b5-crowd-counters.cjs` X0-X4 and 56 of 56 chips PASS, including the facedown stacks from the Corp seat that now use the new look; `fixture-roots.cjs` PASS; run-028 `fixture-search.cjs` B2.1-B2.5 PASS; run-026 `hud-probe.cjs` G4 no new collision category and G5 at 1366x768 median 88.6 (state 05) and 67.8 (state 09), fraction 1.0; run-026 `motion-probe.cjs` 4 of 4; run-025 `audit-ui.cjs` H2 text 0, H3 0, H4 0, H5 0; `harness/verify-action-visibility.cjs` exit 0 (104 of 104 expected actions visible and hit). Remaining limits: as stated in the paragraph above (fanned spines, crowded main cards in the stack column, no fixture with an unrezzed card in a multi-card stack); the spectate Corp view also carries the two new fields, for faces it already shows.

## Table clarity, run-029 round 7 (27 October 2026)

Criterion C6-pile-gallery (player feedback 2026-10-27: many abilities need Archives; tapping Archives should show every card to scroll through and open, like the hand), plus the carry-over defect R5-plaque-occlusion from critic-r05 (a second-row spine counter chip covered the server name, "A2 wd B" instead of "Crowd B"). Gap at round 6: the Archives top card in its column opened a single-card reader, the "Heap" caption opened nothing, and the Corp's Archives gallery did not mark the cards lying facedown. `table/cgs-view.js`: a tap on the Archives or Heap top card drawn on the board opens that pile's gallery, a tap on a stack caption opens its stack's gallery, the gallery heading counts the facedown Archives cards, a FACEDOWN tag (Corp: face with a dashed amber outline; Runner: card back) marks each of them, and the reader of such a card in the Corp's view adds a facedown note. `table/bridge.js` (read-only projection): `facedown: true` on an Archives card the viewer sees while it lies facedown. `table/cgs-scene.js`: stack captions carry `data-pile-label`; on a root card the counter chips (and the stack column with the badge) move off the server name, and a root's stack column is drawn after the server's ICE. `table/cgs.css`: tag, outline, reader note and caption cursor. The same pile paragraph is in `table/README.md` (Play):

Pile galleries (run-029 round 7): tapping a discard pile opens a gallery of every card in it that you may see, a modal dialog that scrolls sideways like the hand gallery; each card in it opens the reader (full card and its legal actions), and Close, Escape or a tap outside closes it, as in the other zone galleries. Archives: tap the top card drawn in the Archives column (shown while Archives has no installed upgrade) or the Archives server chip; the chip's gallery also lists the upgrades and ICE installed on Archives. Heap: tap the Heap pile or its "Heap" caption. Every stack caption (Stack, Heap, Scored, Stolen) opens the same gallery as its stack. Who sees what: the Corp sees every Archives card; a card lying facedown in Archives keeps its face in the Corp's gallery but carries a FACEDOWN tag at its foot (12 CSS px, amber on near-black) and a dashed amber outline, its accessible description reads "Facedown in Archives, hidden from the Runner", and its reader adds "Facedown in Archives · hidden from the Runner". The Runner sees the faceup Archives cards by identity and each facedown one only as a card back named "Facedown card" with the same FACEDOWN tag, never its title or image; its reader shows the back only. The heading counts them, for example "Archives · 5 · 2 facedown". The Heap is public: both players see every Heap card. Projection: `bridge.js` adds `facedown: true` to an Archives card whose face the viewer may see while it lies facedown (the Corp seat and the AI vs AI spectate Corp view); the Runner seat and the spectate Runner view keep the plain facedown projection (key, public counters and hosted cards only). Verification scope: the frozen fixture `.gauntlet/run029-table-clarity/tools/fixture-piles.cjs` (Archives with 3 faceup and 2 facedown cards, a 6-card Heap; both seats; 667x375, 844x390, 1366x768) taps the Archives top card, the Archives chip, the Heap pile and the "Heap" caption, and checks the listing against the engine, scrolling to the last card, the reader of every card, the facedown marking in the Corp's gallery and that no facedown identity reaches the Runner; the Stack, Scored and Stolen captions share the code but no fixture taps them.

The round-5 Counters paragraph (above, and in `table/README.md`) now states the measured rule in place of the earlier claim that the chip pills reach at most 2 CSS px into the server name; the corrected text, identical in both files:

Server name (corrected in run-029 round 7): on a root card no counter chip, root badge or rez cost pill covers the server name. A chip block or stack column that would reach into the name's text box is moved toward the card, badge included, until it reaches at most 1.5 screen px into the server chip plate (run-038 round 1; run-029 measured this from the name's text box); in a crowded stack that move can carry the column across the main card's centre line and past the card's far edge onto the first ICE, so a root's stack column is drawn after the server's ICE (in a group carrying the main card's `data-card-object` key) and stays topmost there. The round-5 text said the pills reach up to 2 CSS px into the bottom of the name; that was measured on the main card's chips only and was false for a spine's second-row chip, which covered "Cro" of "Crowd B" at 667x375.

Fixtures and commands (live tree served by `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9460 .gauntlet/run029-table-clarity/scratch/empty-b7`, outputs under `.gauntlet/run029-table-clarity/scratch/b7-*`): `fixture-piles.cjs` C6.1-C6.7 PASS (baseline r05: C6.1, C6.2 and C6.6 FAIL): from both seats at 667x375, 844x390 and 1366x768 the Archives top card, the Archives chip, the Heap pile and the "Heap" caption all open the gallery, the listing equals the engine, the last card scrolls into view, every card opens its reader, both facedown Archives cards are marked in the Corp's gallery and no facedown identity reaches the Runner. Scratch probe `scratch/b7-crowd-names.cjs` (copy of the round-5 `b5-crowd-counters.cjs` with a server-name check over every chip, badge and rez cost pill of each crowded stack, spines included, and two new kinds midrow2 / midrow2u, where the main card and both spines of the fanned 3-card "Crowd B" carry counters): on the round-6 code the new kinds reproduce the defect (chips 11-13 CSS px deep into "Crowd B" from both seats, name not topmost); on the round-7 code NAMES-CHECK PASS in 28 of 28 case/viewport cells (deepest 1.5 CSS px, every name topmost at its centre), CHIPS-CHECK 0 failing of 96 chips, X0-X2 and X4 PASS (X3 see limits). Frozen gates on the round-7 code: fixture-unrezzed C1.1-C1.6 PASS, fixture-counters C7.1-C7.4 PASS (36 of 36 counters visible), fixture-roots C2.1-C2.2 PASS, crowd-probe X0-X4 PASS, run-028 fixture-search B2.1-B2.5 PASS, run-026 hud-probe 12 of 12 states (G4 categories card+zone-chip and empty-slot+zone-chip only, as round 6; G5 1366 medians 88.6 and 67.8, fraction 1.0) and motion-probe 4 of 4, run-025 audit-ui H2 text 0 fails, H3 0, H4 0, H5 0, and `harness/verify-action-visibility.cjs` exit 0 (95 of 95 expected actions visible and hit).

Remaining limits: in the scratch probe's new kinds midrow2 and midrow2u at 667x375 from the Corp seat, the moved stack column covers the whole exposed strip of one fanned spine (Reduced Service) on its tap line, so that spine opens only through the Crowd B server chip gallery (X3 own tap points 0 there, 1 at 844x390 from the Corp seat, 3 at 667x375 from the Runner seat; the round-5 kinds keep at least 3, X0-X4 PASS for them); where a moved column reaches the first ICE, the badge or chips lie over the ICE's edge. The Archives top card on the board no longer opens a single-card reader (its reader is one tap further, through the gallery). The Stack, Scored and Stolen captions open their galleries through the same code, but no fixture taps them. Not done this round and moved to round 8: R6-peek-occlusion from critic-r06 (on the Corp seat at 667x375 counter chips cover part of an unrezzed card's face peek, and in crowded stacks the rez cost pill lies under the root badge or covers spine peeks); the round-6 Unrezzed look paragraph is unchanged.

## Table clarity, run-029 round 8 (27 October 2026)

Criterion C4-install-chooser (player feedback item 3: choosing where to install hides the board) and C3.3 (trigger thumbnails). The README (`table/README.md`, Decision panel and command tiers) carries these two paragraphs in the same words:

Server-target choices (run-029 round 8): when the engine asks where to install a card (or which server to choose), the decision panel no longer covers the table. It opens without a backdrop, docked where the phase panel sits (the board already keeps that area free), at least 236 CSS px wide and narrowed or shortened so it never covers a server chip, root card or highlighted slot below its top edge, nor the hand; the phase panel is hidden behind it while it is open. Each candidate server is marked on the board with an amber server chip and an amber ring around its root; the engine's new-server entry appears as a dashed amber slot with its own New chip in the next remote column. Tapping a marked server (its chip, its root or ICE cards, or the New slot) submits that server's engine option at once, the same option its panel button submits; the panel buttons still work and still go through the Confirm step. The marked chips and the New slot are keyboard buttons (Tab, then Enter or Space) named "Install <card> on <server>" ("Choose <server>" when no card is involved). When the engine offers several hand cards times servers (Install from the Actions panel), the panel first lists the cards with thumbnails; choosing one shows only that card's servers, and Back returns to the card list. Tapping a hand card and choosing Install goes straight to that card's servers. Hide or Escape closes the panel and keeps the board marks; Choices reopens it; the engine's own Cancel installation appears whenever the engine offers it. The engine's Trash Before Install window that follows an install into a server with cards (Continue, Trash, Cancel installation) stays in the same docked panel. Other decisions keep the centred modal dialog. The table adds no cancel of its own: when the engine offers none, Hide only closes the panel and the choice stays pending. The new bridge field is read-only: each server-target option carries target {serverKey} or {newServer: true}; the submitted option is the engine's own.

Decision rows (run-029 round 8): every option row whose card the viewer may see shows a 30 x 42 CSS px thumbnail of the card's face before its button, triggered abilities included (for example two "When your turn begins" campaigns, each row naming and showing its source card); a row whose card is hidden shows no thumbnail, title or image.

Fixtures (live tree served by `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9470 .gauntlet/run029-table-clarity/scratch/empty-b8`, outputs under `.gauntlet/run029-table-clarity/scratch/b8-*`, details in `receipts/builder-r8.yaml`): fixture-chooser C4.1 PASS (visible board 0.845-0.947, no dimming backdrop), C4.2 PASS (every candidate server marked), C4.3 PASS for Palisade (ICE) at all three viewports and FAIL for Nico Campaign (asset), C4.4 PASS; fixture-triggers C3.1-C3.4 PASS (both trigger rows show the source card thumbnail).

Remaining limits: tapping Fixture R submits the engine option for Fixture R, but because that server already holds a card the engine then opens its Trash Before Install window (Continue, Trash, Cancel installation) in the same docked panel, so the asset lands only after Continue; the frozen fixture checks the card location 1.2 s after the tap and therefore reports C4.3 FAIL for the asset. The table adds no cancel of its own when the engine offers none. At 667x375 the docked panel shows the card and the first two targets, the rest scroll inside it. The R6-peek-occlusion fold-in (unrezzed peek and rez-cost pill under counter chips and badges) and the round-7 two-spine tap-point trade-off (Reduced Service, 667x375 Corp) remain open; nothing in the unrezzed look changed in round 8.

## Table clarity, run-029 round 9 (27 October 2026)

Final build round (critic fold-ins R6-peek-occlusion, R7-two-spine-tap, R7-pile-open-position, R7-archives-a11y-name, R7-docs). The README sentence about each spine's exposed strip is corrected, and the round-6 Unrezzed look sentence about pointer events now points to round 9. The table README adds these two paragraphs (identical text):

Unrezzed placement (run-029 round 9): on an unrezzed card that is not a fanned spine (Corp seat and spectate Corp view), the counter chips sit at the card's right end, away from the peek. The rez-cost pill takes the first free place among the top-right corner, below or above the root badge or a chip, beside one of them, the left edge and the centre, keeping at least 1.2 screen px from every chip and badge (the badge and chips are still drawn over it if no place is free). The pill now takes pointer events itself, so it is the topmost painted element at its centre; the card's own transparent tap area still lies above it, so a tap still opens the reader. The peek window is the first of four shapes that keeps at least 60% of a 5 x 5 sample grid clear of the badge, the chips and the cost pill: the corner window (64% x 50%, ICE 56% x 60%), the top half at full width, the left part at full height, or the whole card face (if none reaches 60%, the clearest one). On a phone-size card with counters it is therefore often the whole face, with the chips and the pill over its right part. Measured with the scratch probe .gauntlet/run029-table-clarity/scratch/b9-peek.cjs (Corp seat, 667x375 and 844x390; peek clear fraction on a 5 x 5 elementsFromPoint grid, cost pill topmost at its centre and its clearance to every chip, badge and other pill): fixture-counters seed Clearinghouse (A6) 0.56 / 0.64 and Kessleroid (A2) 0.64 / 0.76, cost topmost and 1.2 CSS px clear; unrezzed asset + upgrade pair 0.72 / 0.60, cost topmost and 1.2 CSS px clear; every unrezzed ICE in the crowded cases 1.0. Limits (corrected in run-038 round 1): the crowded-stack limit stated here in round 9 (a crowded main card's peek 0.24 to 0.52 clear with its cost pill under a column chip, Clearinghouse 0.56 at 667x375) no longer holds: a multi-card root's chips and badge now sit in the stack bar outside the cards (see Stack bar, run-038 round 1), and the frozen probe `.gauntlet/run038-table-polish/tools/crowd-detail.cjs` measures every unrezzed card that is not a fanned spine at 0.60 or more of its peek clear, with its cost pill topmost and at least 1 CSS px from every chip and badge (Corp seat, 667x375 and 844x390). A fanned spine's peek and cost may be covered by the next spine, as before; open the spine's reader (or the server chip gallery) for them.

Pile gallery opening (run-029 round 9): opening the Archives or Heap gallery (from the Archives top card, the Archives chip, the Heap pile or a caption) scrolls the pile's top card - the most recent one, last in the oldest-first row - fully into view without any scrolling by the player and gives it keyboard focus; it carries a TOP tag at its head (12 CSS px, light cyan on near-black) and its accessible description reads "Top of Archives" (or "Top of Heap"; the accessible name stays the card title). A newly opened gallery starts from its own position and never inherits another gallery's scroll offset; a re-render of the same open gallery keeps its offset. The Archives top card's reader is one tap further than before round 7: the first tap opens the gallery on that card, focused, and a second tap (or Enter) opens its reader. The board's Archives top card is named for what it does, "Archives · N cards · top: <title>" ("top: Facedown card" for the Runner when it lies facedown, with no identity), and is keyboard-reachable from both seats. Installed cards and discards in the Archives chip gallery: the chip's gallery lists the upgrades installed in Archives' root first, then its ICE, then the discard pile oldest first ending with the TOP card, all under one count ("Archives · N"); no divider or second heading separates them, so the order and the TOP tag are the only cues (for the Runner an unrezzed ICE there is a plain back, while a facedown discard carries the FACEDOWN tag). Two-spine stacks and the far-side "Remote 0" / "Remote C" chip overlap (corrected in run-038 round 1): both limits stated here in round 9 no longer hold. With the stack bar no chip lies on a spine, every spine keeps an exposed strip of at least 7.5 screen px, and a server chip plate is never wider than the distance to its neighbour chip's centre minus 1 screen px (see Stack bar, run-038 round 1); the frozen probe `.gauntlet/run038-table-polish/tools/crowd-detail.cjs` measures at least 5 CSS px of own tap points for every root card, both spines of the two-spine case included, and no server chip plate covered by more than 2 CSS px.

Not done this round: R7-two-spine-tap (described above as a limit) and the Runner-seat "Remote C" chip overlap; R6-peek-occlusion is only partly met (limits above).

## Table polish, run-038 round 1 (27 October 2026)

Contract `.gauntlet/contract-run038-table-polish.yaml` v1, round 1: criteria P1-peek, P2-spine-tap and P3-labels (crowded server-root stacks). One structural change in `table/cgs-scene.js`: a root with two or more cards moves its badge and every card's counter chips off the cards into a stack bar on the stack's ICE side, and the server's ICE column moves outward to make room for it. With it, spines keep a minimum exposed strip, server chip plates are capped by the distance to the neighbour chip, and a single root card's chips and badge stop at the server chip plate. The table README adds this paragraph (identical text):

Stack bar (run-038 round 1): on a server root with two or more cards, the root badge and the counter chips of every card in that root leave the cards and form one bar outside the stack, on its ICE side (above the stack on the near side, below it on the far side), 2 screen px clear of the cards: the badge first, then the spines' chips (outermost spine first), then the main card's chips, each pill 1.1 times its text size (text unchanged, at least 12 CSS px), 2 screen px apart, in rows no wider than the column's room (the distance to the nearest neighbour server chip's centre minus 1 screen px; a single wider pill gets a row of its own), rows 1.5 screen px apart, centred on the stack's footprint and kept 2 screen px inside the board. The server's ICE column moves outward until its nearest ICE is 2 screen px clear of the bar, and the bar is drawn after the ICE (the badge and the main card's chips in a group carrying the main card's `data-card-object` key, each spine's chips in a group carrying that spine's key), so it stays topmost. A spine therefore carries no chips, the main card keeps only its peek and its rez-cost pill, and no chip or badge lies on an ICE strength badge. Each spine keeps an exposed strip of at least 7.5 screen px: when the fan is tighter, the main card shrinks below the 70% floor, to no less than 60% of a root card, and its tap area is its own outline once that is at least 24 x 24 CSS px. Server chips: a server chip plate is no wider than the distance to the nearest neighbour server chip's centre minus 1 screen px (its padding narrows from 7 to no less than 2 screen px, then its text shrinks, to no less than 11 CSS px), so neighbouring plates do not overlap. On a single root card the counter chips and the root badge reach at most 1.5 screen px into the server chip plate (run-029 measured this from the name's text box, which let the pills cover up to 7.9 CSS px of the plate). Verification scope: the frozen probe `.gauntlet/run038-table-polish/tools/crowd-detail.cjs` (9 seeded cases: the fixture-counters seed, an unrezzed asset + upgrade pair, HQ with 3 upgrades, three mid-row remotes with 1, 3 and 2 cards, a 5-card remote, facedown variants and a two-spine case with counters on every card, each multi-card root with one ICE; human Corp and human Runner; 667x375, 844x390, 1366x768) measures every unrezzed card that is not a fanned spine at 0.60 (lowest measured 0.64) or more of its peek clear with its cost pill topmost and at least 1 (lowest measured 1.2) CSS px from every chip and badge, every root card at 5 (lowest measured 7) or more CSS px of own tap points on its tap line with a hit rect of at least 24 x 24, no chip, badge or cost pill on an ICE strength badge, no server chip plate covered by more than 2 (deepest measured 1.5) CSS px, and every server name topmost at its centre; the run-029 fixtures (fixture-counters, crowd-probe X0-X4) still pass.

Corrected inside the run-029 sections above (same words in the README): the round-5 Counters text (the stack column now applies only to a single root card whose chips would meet its badge; a spine carries no chips; chips stop at the server chip plate, not the name's text box), the round-6 Unrezzed look limit about the cost pill under the stack column, the round-7 Server name rule (plate, not text box), and the round-9 Limits sentence, two-spine limit and "Remote C" limit, which no longer hold.

Commands (server: `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs <port> .gauntlet/run038-table-polish/snapshots/r01`): `node .gauntlet/run038-table-polish/tools/crowd-detail.cjs --url <server> --out <dir>`; the run-029 set (`.gauntlet/run029-table-clarity/tools/` fixture-roots, fixture-unrezzed, fixture-counters, fixture-piles, fixture-triggers, fixture-chooser-v2, crowd-probe); run-026 hud-probe and motion-probe; run-025 audit-ui; `harness/verify-action-visibility.cjs`. Results are in `.gauntlet/run038-table-polish/receipts/builder-r1.yaml`.

Limits: a spine's counters are no longer drawn next to that spine; the bar orders them (badge, spines from the outermost, then the main card) and the spine's reader shows them. The ICE column of a multi-card root sits further from the root (by the bar's height plus 2 screen px, less the gap that was already there; in the probe cases at 667x375 a one- or two-row bar moves the first ICE 22 to 31 CSS px), so on a phone a server with two or three ICE reaches further toward the middle of the table; only one ICE per crowded root is seeded by the probes. A tight fan with four or more spines in a room narrower than the main card's 60% floor allows can still leave a spine strip under 7.5 screen px (not seeded). P4-P6 (gallery Top tag, choices count, hand under the plaque) are not addressed in this round.

## Table polish, run-038 round 2 (27 October 2026)

Contract `.gauntlet/contract-run038-table-polish.yaml` v1, round 2: criteria P4-gallery-top, P5-dock-count and P6-hand-plaque. Presentation only, in `table/cgs-view.js` (Choices count per visible step, hand lane edge, gallery scroll after image loads) and `table/cgs.css` (TOP tag placement, short-screen docked panel); `table/cgs-scene.js` is unchanged. The table README adds this paragraph (identical text):

Gallery tag, decision count and hand edge (run-038 round 2): in the Archives and Heap galleries the top card's TOP tag sits at the card's foot, centred, 10 CSS px above its bottom edge (28 CSS px higher, above the FACEDOWN tag, when the top card is a facedown discard), so it never covers the printed title band; until round 2 it sat at the card's head, over the title. On open the gallery still scrolls the top card fully into view and focuses it, and it repeats that scroll each time a card image in the gallery finishes loading while the top card keeps focus, so a slow image load no longer leaves the top card off screen. While the docked server-target panel is open, the Choices badge and its accessible name "Choices (n)" count what the visible step offers: the card step counts its cards, the server step its servers; with the panel closed (or for any other decision) the count is the engine's whole option list again. Before round 2 the badge always showed the whole list (for example 26 while 2 servers were offered). On short landscape screens (height up to 500 CSS px) the docked panel trims its header gaps and shows a 23 x 32 CSS px card thumbnail, so the first row of servers is fully visible when the panel opens; further rows are reached by scrolling the panel. Hand edge: when the own plaque shares rows with the hand (the phone sizes), the hand ends 6 CSS px before the plaque's left edge and, to keep its width, starts further left by the same amount, into free floor (at least 8 CSS px from the screen edge and 6 CSS px clear of any board item there), so no hand card, as laid out or scrolled into view, runs under the plaque; seven cards still fit in view at 667x375 and 844x390. Where the plaque does not meet the hand lane, the lane keeps its CSS edges (12% from the left, 18% from the right).

Corrected in the README (not repeated in the run-029 sections of this file): the run-029 round-9 Pile gallery opening text said the TOP tag sits at the card's head; it now sits at its foot (see above). The Portrait paragraph now says that while the docked server-target panel is open the Choices count covers the visible step only.

Verification scope: the frozen probes `.gauntlet/run038-table-polish/tools/gallery-detail.cjs` (P4: Archives and Heap galleries opened by a pointer click on the board pile, human Corp and human Runner, 667x375, 844x390, 1366x768; the TOP tag rect against the printed title band, approximated as x 12%-88%, y 3%-11% of the card image, and the title topmost at its centre; the top entry fully in view and focused on open), `dock-detail.cjs` (P5: an asset and an ICE installed from the hand through the docked chooser at the same three sizes; the badge number against the option rows or card picks of the visible step; buttons-only install) and `hand-detail.cjs` (P6: 7, 9 and 12 cards, both seats, 667x375 and 844x390; hand cards as rendered and scrolled into view against the own `.plaque-frame`; every card at least 24 x 24 CSS px of own tap area). Results and numbers are in `.gauntlet/run038-table-polish/receipts/builder-r2.yaml`.

Commands (server: `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs <port> .gauntlet/run038-table-polish/snapshots/r02`): `node .gauntlet/run038-table-polish/tools/<gallery-detail|dock-detail|hand-detail|crowd-detail>.cjs --url <server> --out <dir>`; the run-029 set (`.gauntlet/run029-table-clarity/tools/` fixture-roots, fixture-unrezzed, fixture-counters, fixture-piles, fixture-triggers, fixture-chooser-v2, crowd-probe); run-026 hud-probe and motion-probe; run-027 font-probe; run-025 audit-ui; run-028 fixture-ob, fixture-search, fixture-wage; `harness/verify-action-visibility.cjs`.

Limits: the TOP tag at the foot covers part of the top card's lower text box (flavour or the last rules line) at the centre, as the FACEDOWN tag already does; the card reader shows the full face. The title band is an approximation of the printed layout, not read from the image. On short screens the docked panel can still show only the first row of servers on open (Palisade at 667x375: 2 of 5 fully visible on open, 3 after Back and re-entry; at 844x390 4 of 5), the panel keeps its run-029 minimum width of 236 CSS px and its other rows are reached by scrolling the panel. The Choices count describes the docked step only; a centred (modal) decision still counts the engine's whole option list. The hand lane edge is recomputed on every render and resize from the plaque and board items present then; it is not re-measured during the motion layer's card travel.

## Run clarity, run-039 round 1 (28 October 2026)

Contract `.gauntlet/contract-run039-run-clarity.yaml` v1 (player feedback item 7: "why didn't my ICE stop the run?"), round 1: criteria R1 run tracker, R2 Corp rez prompt, R3 no silent pass, R4 run recap and R5 no leak. The user chose R3 option A: a notice only, so the run keeps going and no engine file changes (`phase.js`, `init.js` and the AI are untouched; the engine still passes an unrezzed ICE on its own when the Corp has no rez option). Presentation and read-only projection only, in `table/bridge.js` (a read-only run record built from phase changes, enumerated options, card moves and access windows the bridge already observes; seat-specific Run 2.1 labels; the Corp's `n` / `rez` commands marked with `decision`; the step map now names the engine's `Run EncounterEnd` instead of the nonexistent `Run 3.5`), `table/cgs-view.js` (tracker in the phase caption block, decision keys, notice, recap), `table/cgs-scene.js` (`run-target` on the attacked server chip, `run-current` on the ICE the Runner is at) and `table/cgs.css`. No bridge action was added and no command changed. The table README has a new section "Run tracker, rez decision and recap" that describes the tracker, the Corp's rez decision and its labels, the unrezzed-pass notice, the recap, what the Runner and spectators do not see, and how outcomes are known. The README sentences on the single primary command, the run context panel and `bridge.js` are updated to match.

Fixtures (frozen in FRAME, `.gauntlet/run039-run-clarity/tools/`, observable hooks in `INTERFACE.md`): `fixture-run-tracker.cjs` covers R1, R4 and R5 with a human Runner, a human Corp and AI vs AI spectate. `fixture-rez-window.cjs` covers R2, R3 and R5 with a rich Corp that rezzes, a Corp that declines, a poor Corp at 0 credits and a human Runner. Both use the remote "Fixture Run": Whitespace (rezzed, outer), Lotus Field (unrezzed, rez cost 5, inner) and Offworld Office in the root.

Commands (live tree, empty snapshot dir with an empty `manifest.sha256`): `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs <port> .gauntlet/run039-run-clarity/scratch/live-snap`; `node .gauntlet/run039-run-clarity/tools/fixture-run-tracker.cjs --url http://127.0.0.1:<port> --out <dir>` and `fixture-rez-window.cjs` the same way. The regression set is the one in `.gauntlet/run039-run-clarity/run-evidence.sh`: run-038 crowd-detail, gallery-detail, dock-detail and hand-detail; run-029 fixture-roots, -unrezzed, -triggers, -chooser-v2, -chooser, -counters, -piles and crowd-probe; run-026 hud-probe and motion-probe; run-025 audit-ui; `harness/verify-action-visibility.cjs`. `.gauntlet/run039-run-clarity/scratch/reg.sh <port> <outName>` runs that set against a live server. Results and numbers are in `.gauntlet/run039-run-clarity/receipts/builder-r1.yaml`.

Remaining limits: under option A the engine still passes the ICE about 350 ms after the Runner passes, and the Corp only learns why from the notice (6 s, or until it is closed) and from the recap. No fixture seeds the outcomes broken, bypassed, jacked out, trashed or scored by the Corp, or the cannot-rez reason. They are inferred from phase changes, as described in the README. (Corrected in run-043: `.gauntlet/run043-polish/tools/fixture-run-outcomes.cjs` now seeds broken, partly broken, bypassed and jacked-out runs; trashed, scored by the Corp and cannot-rez are still unseeded.) The cannot-afford reason compares the credit pool with the engine's rez cost and ignores credits the Corp could draw from cards. The notice is for the human Corp only; AI vs AI spectate shows none, although the spectate Corp view's recap carries the reason. On phones the recap sits under the commands, and with many commands its lower lines scroll inside the panel. (Corrected in run-043: this was superseded in round 2 - the recap sits above the commands as a compact block. Since run-043 the Corp player's recap also folds to its result line after a turn change, while the Corp has commands, or when it would make the panel cover an installed card; see the table README, "Corp recap folding".) The tracker path wraps onto a second row for servers with many ICE, which no fixture seeds. An ICE approached twice in one run gets two recap lines.

### Round 2 (28 October 2026)

Round 2 follows the fresh critic's verdict on round 1 (REVISE, 70.0 against the 75.0 bar). It changes presentation and the read-only projection only, in `table/bridge.js`, `table/cgs-view.js`, `table/cgs-scene.js` and `table/cgs.css`. No engine file, bridge action or command changed.

- Rez decision: "Rez · <cost>Ⓒ" and "Don't rez" now have their own full-width row, in two equal halves, with the ICE title on a second line of each key. The title is cut with an ellipsis if it is too long and never broken mid-word, and the full label stays in the tooltip and accessible name. When other cards are rezzable, the ordinary Rez command moves to the next row and reads "Rez another card". The engine command is the same. The Corp panel stays within its pre-run-039 height.
- Recap: it moved above the commands as a compact block. It shows the result line with the × key, one line per ICE (wrapping at word boundaries) and an access line cut with an ellipsis. While it is shown, the command keys are 26 px high and the "is deciding…" line is hidden. The Runner panel grows upward from just above the hand lane.
- Credits and reason: the notice, the recap and the rez key keep each number with its credit glyph, and they name the reason as "not enough credits".
- Outcome precedence: subroutines that fired win over broken, with the detail `partly-broken`. An ICE rezzed during the run reports what followed, with the detail `rezzed`. The details appear as `data-detail` and in the visible text.
- Board: `run-current` has a thicker amber edge with a glow. The ICE just passed keeps a faint dashed mark (`run-passed`) during Continue or jack out and during movement.
- Bridge: the access-outcome hook and `runAccess` are wrapped in try/catch. All run-record observers log their own exceptions with `console.debug`.

Builder self-check: `.gauntlet/run039-run-clarity/scratch/r2/probe-r2.cjs` is a copy of the critic's probe with dock geometry added. It seeds three extra remotes with rezzable ICE and finds that no key label overflows at 667x375 or 844x390. The decision panel covers the leftmost remote ICE at 10% and 14% (667x375) and at 2% and 6% (844x390); round 0 measured 12% and 16%, and 8% and 16%. Every recap line, the × key and every command lie inside the panel at 667x375 and 844x390 on both seats. The receipt `.gauntlet/run039-run-clarity/receipts/builder-r2.yaml` has the results.

Remaining limits:
- The Runner panel is up to 54% of the height while a recap shows on phones, and the Corp panel up to 45%. Both can be closed with ×.
- A Corp reason in the recap wraps onto two or three lines at 667 px.
- No fixture seeds broken, partly broken, bypassed or rezzed-then-broken ICE, so those details are inferred from code.

### Round 3 (28 October 2026)

Round 3 fixes a regression from round 2 in `table/cgs-view.js` only. While the recap shows, the Runner panel is anchored from below. When the Runner then chose Run, the docked server chooser opened at the panel's top after its commands had moved out, so it sank onto the hand lane, and the hand row took the tap on the first server button. The chooser now rises to fit its content above the hand (never above the toolbar), as the server-target section already promised. (Corrected in run-043: the round-3 rule left the chooser's header and frame out of its height and only rose when the hand was within 80 px of its top, so before run-043 the chooser reached about 6 px into the hand lane at 844x390 and ended only 6 px above it at 667x375 (FRAME measurement on the fixture board). The run-043 rule keeps the whole chooser box at least 8 px above the hand top.) Nothing closes or switches the chooser on a timer re-render; the race was a covered button, not a closed dialog.

## Deck picker, run-040 round 1 (28 October 2026)

Contract `.gauntlet/contract-run040-deck-picker.yaml` v1 (player feedback item 1: the deck lists showed only names, "not even the ID"). The user chose D1 option A: a picker sheet with rich rows and search. The user's clarification also applies: under the All filter, casual-only decks appear as reference rows that cannot be chosen, with a preview and the reason. The change is in `tools/make-menu-page.mjs` only. `menu.html` is regenerated from it with `node tools/make-menu-page.mjs`. No deck data, format rule, Quick Game pool or launch URL changed. Table, engine, `sw.js`, `offline-assets.json` and `offline.html` are untouched. The README section "Choosing a deck in the menu" describes the picker for players.

What changed in the menu:

- Each of the four slots (`hva-deck`, `hva-opponent`, `aia-corp`, `aia-runner`) has a card `[data-deck-summary]`. Its button `[data-deck-picker-button]` shows the identity art, deck name, identity, faction (dot and name), format tags and card count, and a notes excerpt (about 150 characters, cut at a word) sits under it. An Auto opponent shows the starter deck it resolves to. The cards follow every select change: picker, `change` event, format filter and side toggle.
- One shared `<dialog id="deck-picker" data-deck-picker>` opened with `showModal()`. It has a title naming the seat and the side, a search field (`role=combobox`, rows reached through `aria-activedescendant`), faction chips (`aria-pressed`) and a `role=listbox` list. The list has one `role=option` row per option of the slot's select, in the same order and grouped under sticky faction headings. Next comes the casual-only group (All filter only, `data-casual-only`, `aria-disabled`). A preview panel (`[data-deck-summary][data-preview]`) holds the full field set, about 420 characters of notes and, for casual-only decks, `[data-violation]` with the first reason per format in plain words and a count of further reasons. The preview sits beside the list from 640 px wide and under it (at most 34% of the height) on narrower screens. The sheet fills the screen below 600 px wide or 500 px high, and is 880 x 760 px at most elsewhere.
- Choosing a row sets `select.value` and dispatches one bubbling `change` event, then closes the sheet and returns focus to the slot button. Escape, the Close button and a backdrop click close the sheet without a change.
- The selects keep their ids, options, `data-deck`, optgroups and events. The script adds the class `picker-native` (hidden) to them only after it has built the cards. (Corrected in round 2: the selects are filled by the page script, so without script they are empty and the menu cannot launch; see below.) The slot labels now point at the deck buttons.
- Row thumbnails use `images/<5-digit code>.jpg` (`loading=lazy`, fixed 48 x 48 px box). The slot card and preview images load eagerly. All 45 identity images exist; a missing one is hidden and the faction initials (`[data-identity-art-fallback]`) show instead.
- The format status line under All now reads "70 casual-only decks for reference only" instead of "hidden".

Fixtures (frozen in FRAME, `.gauntlet/run040-deck-picker/tools/`, hooks in `INTERFACE.md`): `fixture-deck-info.cjs` (M1 rows in 18 cells, M3 summaries) and `fixture-deck-browse.cjs` (M2 rows against options, search, chips, pointer and keyboard choice, Escape, launch URL parity, casual rows; M4 layout at 375x812, 667x375, 844x390 and 1366x768).

Commands (live tree, empty snapshot dir with an empty `manifest.sha256`): `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs <port> .gauntlet/run040-deck-picker/scratch/live-snap`; `node .gauntlet/run040-deck-picker/tools/fixture-deck-info.cjs --url http://127.0.0.1:<port> --out <dir>` and `fixture-deck-browse.cjs` the same way. Regressions as in `.gauntlet/run040-deck-picker/run-evidence.sh`: `harness/verify-menu.js`, run-025 `audit-ui.cjs`, run-027 `font-probe.cjs --family "Exo 2"`. `.gauntlet/run040-deck-picker/scratch/reg.sh <port> <outName> [full]` runs that set against a live server. Round 1 results: both fixtures PASS (26 of 26 clauses), verify-menu ok, audit menu states 0 in H2 text, H2 UI, H3, H4 and H5, font probe Exo 2 only. The numbers are in `.gauntlet/run040-deck-picker/receipts/builder-r1.yaml`.

Remaining limits:

- On touch screens tapping a choosable row chooses it at once. The preview of another deck's notes is reached by hover (mouse), the arrow keys or after choosing (the slot card). Casual-only rows always preview.
- At 667x375 the list shows about three rows next to the preview; the rest scroll.
- Deck notes are the precon data as written. NRDB imports carry technical import notes (URL, implemented card lines) rather than a description.
- The sheet rebuilds its rows on each open (up to 138 rows). Lazy thumbnails load as rows scroll into view.
- Not tested by a fixture: the summary of Auto, Tab order inside the sheet, and screen-reader announcements beyond roles and states.

### Round 2 (28 October 2026)

The fresh critic's round-1 review (`.gauntlet/run040-deck-picker/verdicts/critic-r01.yaml`, REVISE at 72.5) found developer text on the front-end and a wrong no-script claim. Round 2 changes `tools/make-menu-page.mjs` only (and regenerates `menu.html`), plus this section and the README menu subsection.

- **Deck descriptions without developer text.** 139 of the 210 decks (131 of the 140 choosable ones) are NetrunnerDB imports whose precon `notes` hold the import log: source URL, "Card lines implemented", format classification by `harness/lib/formats.js`, importer tool and date. The generator now publishes in `#menu-data` `notes` only player-facing text: `playerNotes()` drops sentences about the NRDB import, URLs, implemented card lines, the format classification, printings, repo paths, run ids and "Imported by". For the 139 imports nothing is left, so their `notes` is `""`. The 71 hand-written descriptions are unchanged, byte for byte. The generator fails if any published description still contains a URL, a repo path, a run id or import wording. Where a deck has no description, the slot card shows no description line and the picker's details panel shows no description paragraph. The precon files keep their notes, and the launch parameter (which carries the notes for the engine's deck-info popup) is unchanged, so launch URLs are identical. No other `#menu-data` field changed. `notes` is read only by the menu page itself: `harness/verify-menu.js`, `check-table-delivery.js` and the run-040 fixtures do not depend on the import text (the fixtures compare the first 60 characters of the published `notes`, which is now empty for imports).
- **No-script wording.** The selects are filled by the page script, so without script they are empty and nothing launches. README, the round-1 bullet above and the generator header now say so.
- **Picker after the menu's init.** The picker script now runs after `setFilter("all")` and `setSide("corp")`, inside a `try`/`catch`. If it fails, the catch shows the filled native selects again (labels pointing at them), so the menu still works.
- **List focus.** `#deck-picker-list` has `tabindex="-1"`, so Tab no longer stops on an invisibly focused list (Tab goes Close, search, chips). If a click focuses the list, focus returns to the search field, where the arrows and Enter work.
- **Hover preview.** Mouse hover previews a row only after the pointer has moved a few pixels since the sheet opened. A resting cursor under the opening sheet no longer replaces the selected deck in the details panel.
- **Touch preview.** On touch screens (`(hover: none), (pointer: coarse)`) each row has a 44 px info key at its right end. Tapping it shows that deck in the details panel without choosing it; tapping the rest of the row still chooses it. The key is `aria-hidden` and not focusable: keyboard and screen-reader users preview with the arrows. On those screens the details panel says "Tap the deck to choose it".
- Small fixes: faction headings in the list use the same spelling as the chips ("Haas-Bioroid"); the details panel is a named region ("Deck details").

Round 2 results on the live tree (`node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9411 .gauntlet/run040-deck-picker/scratch/live-snap`, then `.gauntlet/run040-deck-picker/scratch/reg.sh 9411 b3/final full`): both fixtures PASS (26 of 26 clauses, no page or console errors), verify-menu ok (all flows true), audit menu states 0 failures at 667x375, 844x390 and 1366x768, font probe 39 of 39 COMPLETED with Exo 2 only. The critic's probes, re-run against the live tree (`.gauntlet/run040-deck-picker/scratch/r2probe/`), and a new scan (`devtext.cjs`) found no `harness/`, `run0`, `Imported by`, `Card lines`, `classified by` or `http` in 939 slot cards (every option of every slot under all three filters and both sides) or in 1167 picker previews (every row, casual rows included). Details are in `.gauntlet/run040-deck-picker/receipts/builder-r2.yaml`.

Round-1 limits that no longer apply: the touch limit (the info key now previews) and the NRDB-notes limit (import notes are no longer shown). Still true: about two or three rows fit beside the preview at 667x375 and 844x390; the list rebuilds on each open (up to 139 rows with the Auto row); the info key is a pointer-only aid; NetrunnerDB imports have no player-facing description until the deck data gets one.

## Undo, run-042 round 1 (28 October 2026)

Contract `.gauntlet/contract-run042-undo-build.yaml` v1 (player feedback item 9, "no undo"; run-041 D1 option A). In a Human vs AI game in the table view the human can undo their own last action, repeatedly back through the current turn, as long as the undone interval revealed nothing and the AI made no choice. The mechanism is an in-page snapshot of the engine state before each human decision, restored directly; nothing is replayed. `table/README.md` (section Undo) describes it for players: when it is offered, the refusal reasons, what it restores and where it is disabled. No new files: `sw.js`, `offline-assets.json`, `offline.html`, `engine.php`, `tools/make-*.mjs` and the menu files are untouched.

What changed:

- **Engine randomness** (`utility.js`, `command.js`, the AI files and six set files). `rand()` is the one gameplay random function and `RandCount()` counts its calls since page load. `RandomRange` (and so `Shuffle`), the unseeded `LCG()` seed, `ai_runner.js` server jitter, `ai_corpdecept.js`, `ai_runcortex.js`, the 12 direct sites in `sets/elevation.js`, `liberation013.js`, `newerpacks.js`, `systemgateway.js`, `systemupdate2021.js`, `tranche014.js` and the tutorial agenda picks in `init.js` call `rand()`. The old unused global `var rand = LCG()` in `command.js` is gone. When `window.__ENGINE_SEED__` is a number as `utility.js` runs, `rand()` uses its own mulberry32 stream and never calls `Math.random` (test harnesses); otherwise it draws from `Math.random` (normal play, and the headless harness, which seeds `Math.random` itself). The UI keeps `Math.random`. Rule for future card code: game logic uses `rand()`, the UI keeps `Math.random`.
- **Undo engine** (`UndoEngine` at the end of `utility.js`). Snapshot: every object reachable from the engine's game-state globals (an explicit list plus the engine's other own globals found once at the first snapshot, minus settings, view and harness globals), the lexical `stackedLog`, the AI objects' own fields (CorpAI, RunnerAI, RunCalculator) and the bridge's record objects, per object its own keys and values (references kept). `capturedLog` is append-only and is truncated on restore. Restore writes the keys back, reassigns the globals, then the bridge restores its own closure state. Interval monitor: hooks on `ChangePhase` (turn change, unrezzed ICE passed, a visibility scan of every card for the human seat before and after each call), `Shuffle`, `TriggeredResponsePhase` (access), `MoveCardTriggers` (draw), the AI choice call sites in `init.js` Main and `command.js` MakeChoice, `RandCount()`, and the main-loop timer (`command.js` ResolveChoice schedules, Main runs; a pending loop is `not-settled`). Snapshots are dropped when the turn changes; the last decision stays as a record so the refusal still names every reason.
- **Bridge** (`table/bridge.js`). A snapshot is taken before every applied decision (`actions[i].activate()`, `cardActions[i].activate()`, `options[i].activate()`, `confirm.activate()`, cancel); a refused activation discards it. `VisionTable.undo()`, `read().undo` and `VisionTable.undoTimings()` follow `.gauntlet/run042-undo-build/tools/INTERFACE.md`; `VisionTable.subscribeUndo(fn)` lets the view update the control in the same task. After an undo the hidden classic renderer re-reads counters and positions (`UpdateCounters`, `Render`); its history panel stays scrubbed in the table view, so there is nothing to truncate there.
- **View** (`table/cgs-view.js`, `table/cgs.css`). The Undo key `[data-undo]` sits in the toolbar after Actions; while the decision dialog is open it moves into the dialog header; at 720 CSS px wide or less it is an icon in the top-right corner of the phase panel, clear of the opponent hand fan. Disabled: `aria-disabled="true"`, `data-undo-reason`, the reason in plain words in the tooltip. Hidden in AI vs AI spectate.
- **Classic renderer** (`cardrenderer/cardrenderer.js`): Auto-skip does not fire while an undo is possible.
- `utility.js`: the product's global `JSON.stringify` wrapper took a standard replacer function as its `setNumbers` flag and ignored it. It now applies a replacer function first. No product code passes one; the run-042 bridge fingerprint (which strips activity timestamps with a replacer) depends on it.

Tools (frozen, `.gauntlet/run042-undo-build/tools/`, interface in `INTERFACE.md`): `fixture-undo-eligibility.cjs` (B3/B4, scenarios E0-E12), `fixture-undo-fidelity.cjs` (B1), `fixture-undo-latency.cjs` (B2), `fixture-undo-view.cjs` (B5), `determinism.cjs` (B6), `smoke-batch.cjs` (B7 smoke), `run-evidence.sh <rNN> <port> [full]` (all of them plus the B7 regression set).

Commands (live tree): `node .gauntlet/run025-ui-aaa/tools/overlay-serve.cjs 9461 .gauntlet/run042-undo-build/scratch/r1/serve` (an empty `manifest.sha256` serves the live tree), then for example `node .gauntlet/run042-undo-build/tools/fixture-undo-eligibility.cjs --url http://127.0.0.1:9461 --out <dir>` and `fixture-undo-fidelity.cjs --url http://127.0.0.1:9461 --out <dir> --games 4 --lanes 3`. Round-1 results are in `.gauntlet/run042-undo-build/receipts/builder-r1.yaml`.

Limits:

- State held only in closures of card scripts or engine callbacks is not part of the snapshot; an undo across a change of such state would not revert it. None was seen in the quick-pool decks.
- Objects created after the first snapshot as new window globals are not snapshot roots.
- The snapshot is in memory only: it is dropped at the change of turn and on reload. No save/load.
- Narration (speech) callbacks are not tracked as unsettled work; with narration on, undo may be offered while a narration callback is pending.
- On phones the Undo icon is 30 x 24 CSS px, smaller than the other keys.

Round 2 (29 October 2026, contract v2 `v2_extension` BUILD-r2):

- **Draws by any code path** (`utility.js` `UndoEngine`). Each decision's snapshot also keeps the set of cards then in R&D and the Stack; a card of that set found in HQ or the Grip at any later check of the same interval (every phase change, every card move, and when the interval's reasons are read or frozen) refuses the interval with `drew`, as the `MoveCardTriggers` hook already did for ordinary draws. This covers card scripts that move a card from a deck to a hand directly (MuslihaT's "Reveal and add to grip" splices the Stack and pushes into the Grip); no card script was changed. The refused interval also ends the undo chain, so a later clean action is undoable with depth 1 only.
- **One board draw per undo** (`table/cgs-view.js`, `table/bridge.js`). The render after an Undo click draws the scene once, after the panels are laid out (before, it drew with the old panel rectangle and again with the new one); the dialogs the click closes are cleaned up at once, so their close events add no further render; the bridge no longer re-syncs the hidden classic renderer (`UpdateCounters`, `Render`) after an undo, which cost a counter pass and a second render. Round-1 text above that says the classic renderer re-reads counters and positions after an undo no longer holds.
- Tools v2 (`tools.sha256` re-frozen in FRAME-v2) add eligibility scenario E13 (the MuslihaT reveal-add) and latency on pre-scanned seeds at round 8 or later; results in `.gauntlet/run042-undo-build/receipts/builder-r2.yaml`.

Round 3 (29 October 2026, contract v3 `v3_extension` BUILD-r3), after the PROFILE-v3 trace (`.gauntlet/run042-undo-build/receipts/profile-v3.yaml`):

- **Decision panel closes made in code are quiet** (`table/cgs-view.js`). The Undo click, `renderCore` switching the panel between docked and modal, and `openChoices` now close it through one helper. The helper runs the panel cleanup (`choicesClosed()`) at once and counts the close event off, so that event queues no second full render. Before this, a single flag marked the next close event as quiet. Close events arrive one frame later and in order, so the flag could be used up by an earlier event, and the Undo render then ran twice. On points where the panel is docked, the switch close also re-rendered the table and closed the panel again in a loop (about 30 board draws a second while the player was deciding). Closes the player makes (Escape, backdrop click, Hide, the decision buttons) still clean up and re-render as before.
- **Label widths are cached** (`table/cgs-scene.js`). Board label widths (chips, captions, server and HUD names, badges) are kept across renders. The cache key is the label markup, its class, font size, weight and letter spacing, a counter ancestor, the svg id, its CSS box, `devicePixelRatio` and the scene width. Nothing is cached while a font is loading, and the cache is cleared when fonts finish loading. The drawn board is the same: the SVG markup after an undo matched round 2 on 24 of 24 sampled points (timing-only animation delays and the event pulse class left out of the comparison).
- The hidden classic renderer's PIXI ticker still runs in the table view (it starts the game after textures load). Pausing it was not needed for the latency bar and is left as a follow-up. Results are in `.gauntlet/run042-undo-build/receipts/builder-r3.yaml`.

## Polish, run-043 round 1 (29 October 2026)

Contract `.gauntlet/contract-run043-polish.yaml` v2 (one polish run for the critic follow-ups of runs 039, 040 and 042). The user chose **D1 option A** (remove the import log at the source: the menu generator puts only player-facing notes into the launch param; `init.js` is not touched) and **D2 option A** (a generated "deck at a glance" for every deck without a hand-written description). Contract v2 added: P6 is judged in landscape only (375x812 portrait shows the rotate screen, so there is no Undo key to tap); P7 is read strictly (the Corp recap must cover no installed card in the Runner's turn right after the run, too); the real-AI cell of P8 is recorded only; NRDB deck names in the in-game Decks panel are decoded. No rule, engine, AI, card, set, deck list, format, pool or undo-mechanism file changed; `sw.js`, `offline-assets.json` and `offline.html` are untouched.

What changed:

- **Developer text out of the game** (`tools/make-menu-page.mjs`, regenerated `menu.html`). `playerNotes()` now drops a sentence only when it starts with one of the import log's own openings (`NRDB import of "<name>" (<url>).`, read as one sentence whatever punctuation the name holds, `Card lines implemented`, `All cards implemented`, `Format-legal:`, `Imported by`, `Printings stored as`). A note with no such sentence is published byte for byte, a note made only of them is published empty, and a mix fails the build ("partly dropped", naming the precon file), so a hand-written sentence mentioning `tools/`, `physical cards` or a run id is never removed silently (the run-040 filter dropped any sentence containing such words). The same function fills `#menu-data` `notes` and the `notes` in each launch param, which is left out when empty: the 139 NetrunnerDB imports no longer carry their import log into the game, so neither the table's Table tools -> Decks panel nor the legacy deck-info popup shows it. The 71 hand-written notes are unchanged byte for byte, and identity, cards, name and url of all 210 launch params are identical to before. A build guard fails if import-log wording (`NRDB import of "`, `Card lines implemented`, `Imported by tools/` ...) is left in any published note. Test hook: `node tools/make-menu-page.mjs --notes-filter-test <cases.json>` reads only that file, writes nothing and prints the published notes (exit 3 and `<id>: note partly dropped` on stderr for a mix).
- **Deck at a glance** (generator and `table/cgs-view.js`). A deck without a description shows its card-type mix in a fixed order (Corp: ICE, agendas, assets, upgrades, operations; Runner: events, hardware, programs, resources; zero counts left out) and its three most-copied cards (copies descending, ties by title, then code), identity excluded, on the slot card, in the picker's details panel and in the table's Decks panel (computed there from the launch param's cards and the engine card data). Types and titles come from `carddata/carddata.json`; for the 38 reprint codes it lacks (sets 27xxx / 36xxx) from the engine definition in `sets/*.js`, and when that takes its type from a shared body at load time, from the carddata card of the same title. All 140 choosable decks now show a description or a glance. The Decks panel prints the deck name and notes as plain text with HTML entities decoded (`MYOSHUED REGARDS (JAI'S JANKIEST JOKESTER)` instead of `&#039;`).
- **Picker** (generator). On short landscape screens (height <= 500 px, width >= 600 px) rows take two lines (name; identity, format tags, card count; 36 px art), so at least four rows show at 667x375 and 844x390; the faction stays as the colour bar and the heading. The details panel, a Tab stop when its content overflows, shows the menu's yellow ring instead of the browser's. A chip reached with Tab scrolls fully into the strip (`scroll-padding-inline` keeps it clear of the fade); an overflowing strip fades out (CSS mask) at the side that holds more chips, and has no fade when every chip fits.
- **Undo refused look and reason** (`table/cgs.css`, `table/cgs-view.js`). The refused key has one look in the toolbar, the dialog header and the phone phase panel: flat dark face, dashed `#6b8299` edge, `#a9bccd` label and icon (contrast 9.8:1 on the face), no hover glow, no pressed shift. The run-042 rule lost in the toolbar to the cut-corner key style (`#viewport :is(...)`, specificity 1,4,1) and lost its label colour everywhere to the shared `color:var(--accent-ink)!important`; the new rule uses `#viewport#viewport` and `!important`. The offered key is unchanged. A tap or click on a refused key shows the reason (the tooltip words) in a note under the key for 4.5 s, announced through `role="status"` / `aria-live="polite"` (`[data-undo-why]`); it never undoes and moves no board card.
- **Docked chooser and recap** (`table/cgs-view.js`, `table/cgs.css`). The docked chooser's whole box (header and frame included) ends at least 8 px above the hand lane whenever there are at least 110 px between toolbar and hand. In the Corp player's view the run recap folds to its result line after a turn change, while the Corp has commands, and in the Runner's turn right after the run when the full recap would make the panel cover an installed card (nine hit points per board card face, checked after each render and resize; a fold for a given run and window size is kept). The result line wraps instead of being cut (10.5 px when folded).
- **Run record** (`table/bridge.js`, CRLF kept). A paid ability used in the approach window no longer adds a second and third entry for the same ICE (the engine re-enters Run 2.1; the record keeps one entry until the ICE is encountered or passed). A jack out is recorded as "Runner jacked out" whoever asks for it: the bridge counts the engine's `JackOut()` (observation only, like its other wrappers), so an AI Runner's jack out, which reaches `JackOut()` after the wrapped `ExecuteChosen` has returned, is no longer recorded as unsuccessful. No engine or undo code changed.

Fixtures (frozen in FRAME, `.gauntlet/run043-polish/tools/`, hooks in `INTERFACE.md`): `fixture-game-deckinfo.cjs` (P1), `fixture-notes-filter.cjs` (P2), `fixture-deck-summary.cjs` (P3), `fixture-picker-polish.cjs` (P4), `fixture-undo-states.cjs` (P5, P6), `fixture-recap-layout.cjs` (P7), `fixture-run-outcomes.cjs` (P8); `run-evidence.sh <rNN>[-tag] <port 9471-9479> [full]` runs them with the run-039 / run-040 / run-042 regression fixtures, `harness/verify-menu.js`, the run-025 audit and capture, the run-027 font probe and the desktop undo-latency lane on a snapshot.

Commands: from `chiriboga/`, `node tools/make-menu-page.mjs` (writes `menu.html` only). Fixtures against a snapshot: `bash .gauntlet/run043-polish/tools/run-evidence.sh r01 9473`; a copy of the live tree can be served with `SNAPDIR=<dir with files/ and manifest.sha256>` (the builder used `.gauntlet/run043-polish/scratch/b1-sync.sh` to make one). Single fixture: `node .gauntlet/run043-polish/tools/fixture-recap-layout.cjs --url http://127.0.0.1:<port> --out <dir>` with `node .gauntlet/run042-undo-build/tools/overlay-serve.cjs <port> <snapshot dir>` running. Results: `.gauntlet/run043-polish/receipts/builder-r1.yaml`.

Remaining limits:

- Old launch URLs saved before this run still carry the import log in `notes`; the legacy popup (`init.js`, not in scope under D1-A) shows it for them. The table's Decks panel shows such notes too (it prints whatever notes the URL carries).
- The deck at a glance in the table uses the engine's card types and titles for every code; the menu uses carddata first. They agree for the pinned decks; a card whose engine title differs from carddata could order a tie differently. (Corrected in run-043 round 2: it did, for one choosable deck; the table now uses carddata's spelling, see below.)
- A folded Corp recap has no "show details" key; the full run is in the History panel. The fold check needs the board drawn; a recap shown while the board is still animating is checked again at the next render.
- A long result line ("Runner jacked out · Remote 12") can wrap to two lines in the narrow Corp panel at 667 px.
- Rows on short landscape screens hide the faction name and dot (the colour bar and the group heading keep the faction); the picker's other layouts are unchanged.

Round 2 (30 October 2026) folds in the fresh critic's round-1 verdict (`.gauntlet/run043-polish/verdicts/critic-r01.yaml`, REVISE at 75.0; P3 and DOC failed), in `table/cgs-view.js`, `table/cgs.css` and the docs only:

- **Same glance as the menu for every deck.** The table took card titles from the engine, which writes four of them with straight quotes where `carddata/carddata.json` (and so the menu) has typographic ones (`"Knickknack" O'Brian`, `The Maker's Eye`, `Aesop's Pawnshop`, `Tomorrow's Headline`). A straight quote sorts before letters, so the deck `nrdb-to-pimp-a-butterfly-2nd-4th-8th-and-10th-warsaw-mc` listed Knickknack first in the game and not at all in the menu. The Decks panel now shows and sorts carddata's spelling for those four codes (the only engine titles of the 401 engine cards in carddata that differ from it). An all-decks check (the critic's probe, run on the real `deckGlance` code from the served `cgs-view.js`) gives 0 differences between the table glance and the menu glance for all 210 decks, 139 of which show it in the Decks panel. The generator and `menu.html` are unchanged.
- **The reason note takes taps.** The Undo reason note lay over the first command row (phase panel, decision panel header) and let taps through for its 4.5 s, so a tap there fired a command the player could not see. A tap or click on the note now only dismisses it. Everything else is as in round 1 (shown at least until the next action or 4.5 s, polite status, no board card moves).
- **Chooser slack.** The docked chooser aims at 9 px above the hand (the rule stays "at least 8 px"), so subpixel layout cannot bring it to 7.x px.
- `README.md` is CRLF again, as before the run (round 1 had saved it as LF).

Results: `.gauntlet/run043-polish/receipts/builder-r2.yaml`.

## Stability, run-044 (30 October 2026)

Contract `.gauntlet/contract-run044-stability.yaml` v1 (backlog step D). The user chose **D1 option A**: card fixes only, no guard against negative credits in the engine core. Changed: the Mycoweb 35053 definition in `sets/elevation.js` (CRLF kept), the Boomerang 26075 definition in `sets/newerpacks.js`, the Boomerang test in `harness/smoke-tranche-run013.js`, the new `harness/smoke-run044.js`, and this file. No core file (`utility.js`, `mechanics.js`, `phase.js`, `runcalculator.js`, `ai_*.js`, `init.js`), no other card or deck; `sw.js`, `offline-assets.json` and `offline.html` are untouched.

**Peculiarity stall (Mycoweb double charge).** Mycoweb's second subroutine ("You may rez 1 installed piece of ice, paying 2[c] less") paid the reduced cost with `SpendCredits(corp, rezCost, "rezzing", ...)` and then called `Rez(card)` without `ignoreAllCosts`, and `Rez` (`mechanics.js`) charges the full rez cost again. The Corp paid N-2 plus N for one rez. With few credits the pool went negative; at the next Corp turn the AI found no legal command ("No valid commands available" / "Null command" at every step) and the game never ended. This is the stall of the AI-vs-AI smoke games 4854 (Peculiarity vs Core Topan: 11 credits, 8 for Mycoweb, 1 for the discounted Palisade, then 3 more for the full Palisade = -1), 4846 and 4856 (the run-042 r00-v2 deal: Whitespace at the discounted 0, then the full 2 = -1). Mycoweb is in 4 precons (1000 Cuts, Fashion Lab, Hams Elevation Corps, Pecularity). Fix: the rez inside the `SpendCredits` callback is `Rez(iceParams.card, true)`, so the Corp pays max(0, N-2) once. The offer list holds only ice the Corp can pay for at the reduced cost, plus Decline; since round 2 it also leaves out ice with a mandatory additional rez cost (see Round 2 below). The rest of the card is unchanged. Seed 4854 now ends CORP_WINS in about 7 s (1308 log lines) and no credit pool goes below 0.

**Boomerang rule as implemented.** Card text: "[trash]: Break up to 2 subroutines. When this run ends, if it was successful, you may shuffle 1 copy of Boomerang from your heap into your stack." Before this run the recursion was never offered: the run-end hook had no `availableWhenInactive`, so the copy in the heap never saw the run end, and its `Enumerate` cleared the success flag, so adding `availableWhenInactive` alone gave the run-025 class A deadlock (the engine calls `Enumerate` more than once; the second call returned nothing and the phase had no legal command). Now:

- The ability's trash marks that copy (`trashedForAbilityThisRun`). When the run is declared successful (`responseOnRunSuccessful`, automatic), a marked copy records the success (`runWasSuccessful`).
- At the run end (Run 6.4) the Runner is offered the recursion ("Shuffle" / "Decline") once, for the marked copy, when the run was successful and the copy is still in the heap. Shuffle moves that copy into the stack and shuffles the stack ("Boomerang shuffled from the heap into the stack"); Decline leaves it in the heap.
- No offer when the run was unsuccessful (for example the second end-the-run subroutine of Eli 1.0 ends it), for a copy trashed in an earlier run, or for a copy that reached the heap any other way. With two copies in the heap only the copy trashed in this run is offered.
- `Enumerate` only reads the two flags, so calling it twice gives the same answer. The run-end, run-success and run-begin hooks are `availableWhenInactive`. Both flags are cleared when any run begins (`automaticOnRunBegins`) and when the recursion resolves, whatever the choice, so nothing carries into a later run.
- The AI Runner keeps its preference to take the recursion. Attune, the ability and its AI choices are unchanged.

**Smokes.** `harness/smoke-run044.js` (from `chiriboga/`: `node harness/smoke-run044.js [--out <file.json>] [--only mycoweb,boomerang,seed4854]`, exit 1 on any failed check) boots the real engine in-process (seed 4401, Gateway Corp vs Gateway Runner) and has 14 smokes. Mycoweb: rez cost N = 5, 3, 2 with 10 credits lowers the pool by exactly max(0, N-2) with one rez payment; Decline changes nothing; N = 3 with 1 credit and N = 2 with 0 credits rez and leave 0; with 1 credit only Whitespace, Wall of Static and Eli 1.0 are offered (not Lotus Field), plus Decline; with a scored agenda and 10 credits Archer is not offered while Wall of Static is (rezzed for 1, the agenda kept); Biawak (rez 14) is offered at 12 and 15 credits drop to 3 with one rez payment and no forfeit. Boomerang, every scenario a real run (the `install` command and its attune trigger, the `run` command, every run phase one command at a time, the ability through the `trigger` command in the encounter; no flag set by hand and no hook fired directly): (a) take and decline, (b) unsuccessful run, then a successful run in the same turn, (c) a copy trashed in an earlier run and a copy put straight into the heap, (d) two copies, (e) `EnumeratePhase` and the card's `Enumerate` twice at Run 6.4, (f) the run end handed to the engine's `Main` loop with the real AIs: the run ends within 50 steps, no "No valid commands" line, the copy is in the stack. Last, seed 4854 must finish with no negative credit pool. On the run-044 r00 card files 3 of the 14 smokes pass (Mycoweb Decline and offer list, Boomerang (b)) and 11 fail; on the round-1 Mycoweb definition the Archer smoke fails (Archer offered). The Boomerang test of `harness/smoke-tranche-run013.js` used to set `runWasSuccessful` by hand and fire the hook directly; it now plays a real successful run with the same three check names plus "the run is successful and ends through the engine's run phases" (61/61 cards, 218 checks).

**Frozen tools.** `.gauntlet/run044-stability/tools/` (interface in `INTERFACE.md`, hashes in `tools.sha256`): `fixture-cards.cjs` (S0-S2: Mycoweb, Boomerang (a)-(f), seed 4854 with the negative-credit detector of `r44-lib.cjs`), `replay.cjs` (S5: the 4 Mycoweb precons x 4 quick-pool Runners and the 21 Boomerang precons against Gateway Corp, seeds 24-26, 111 games), `s3-summary.cjs`, `s4-check.cjs` (with the reused run-042 `smoke-batch.cjs`, seeds 4801-4880), `s6-bytes.cjs` (line endings, SPDX headers, scope and diff hunks) and `run-evidence.sh <rNN>[-tag] [full]`. For example `node .gauntlet/run044-stability/tools/fixture-cards.cjs --out <dir>`. Results: `.gauntlet/run044-stability/receipts/builder-r1.yaml` (round 1) and `builder-r2.yaml` (round 2).

**Round 2 (30 October 2026)** folds in the fresh critic's round-1 verdict (`.gauntlet/run044-stability/verdicts/critic-r01.yaml`, REVISE; DOC failed), in the Mycoweb definition, `harness/smoke-run044.js` and this section only:

- **B1.** The limit note named Plutus' forfeit or trash as a rez cost Mycoweb skips. Plutus is an asset and Mycoweb offers only ice, so the note was wrong; it now names Archer and Biawak (below).
- **NB1.** `Rez(card, true)` let Mycoweb rez Archer 31075 (`additionalRezCostForfeitAgenda`, a mandatory agenda forfeit) without the forfeit. Mycoweb's offer list now leaves out ice with a mandatory additional rez cost (`additionalRezCostForfeitAgenda` or `additionalRezCostForfeitOrTrashThree`). The discounted rez still pays the reduced cost once and then calls `Rez(card, true)`, so the stall stays fixed. Biawak 35074's forfeit is optional (`optionalForfeitRezReduction`), so Biawak stays offered and is rezzed at max(0, N-2) without the forfeit, which is legal. Two new smokes check both cases.
- The section title no longer names a round.

Remaining limits:

- Mycoweb's AI choice is still not coded: the AI logs "I don't have code to handle this situation" and takes the first option (the first affordable ice, never Decline).
- Mycoweb's discounted rez ignores all other rez costs. Ice with a mandatory additional rez cost (Archer 31075's agenda forfeit) is therefore not offered at all, even when the Corp could pay that cost. Biawak 35074 is offered, but its optional forfeit (10[c] off the rez cost) is not offered through Mycoweb, so the Corp pays max(0, N-2) in credits. None of the 4 Mycoweb precons holds Archer or Biawak.
- D1-A: the core has no guard against negative credits. A different card with the same fault would again drive a pool below 0; the run-044 smoke and replays detect it, the engine does not prevent it.
- The Boomerang replay check (S5.4) rests on few games: at r00 the AI used the trash ability in only 2 of the 63 Boomerang games.
- "Shuffle" always moves the copy that was trashed in this run. The card text says "1 copy of Boomerang from your heap", so a player could in principle pick another heap copy; the outcome (one Boomerang from the heap into the stack, then a shuffle) is the same.
