# Phase 2: Client Engine Documentation - Research

**Researched:** 2026-05-02
**Domain:** Reverse-engineering documentation + asset cataloguing tool + 2D-engine selection (Phaser 3 / Phaser 4 / PixiJS 8)
**Confidence:** HIGH

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

#### Scope of Engine Surface (CDOC-01)
- **D-01:** In-game UI / HUD / menus documented at the same depth as engine primitives. Subsystem MDs cover Main_Menu, Online_Lobby, Settings_Menu, Online_Command_Screen, inventory UI, message-board reader.
- **D-02:** Original admin model (`Ctrl+E` clipboard RCE, `Ctrl+Q` inspect, `,ServerCommands.txt`, `Ctrl+O Codes.txt`) documented as **anti-port reference** in `docs/extracted-engine/admin-anti-port.md`. Each command/keybind catalogued with original behavior + `REJECTED-AS-PORTED` marker + reason.
- **D-03:** For DnD-only objects/events, engine docs cite the canonical `.dnd.json` descriptor path. Best-effort transcompiled `.gml` may be quoted inline prefixed with `// see descriptor for canonical truth`.
- **D-04:** Each documented feature in CDOC-01 carries an explicit `mvp: yes | no` tag identifying whether it is required for the CLI-08 Phase 6 MVP gate (two players, one room, walk + chat).

#### Asset Catalogue (CDOC-02)
- **D-05:** Build `tools/asset-catalog` as a TS Node CLI (same conventions as `tools/extract-gmd`). Reads `extracted/<src>/**/meta.json` and emits `asset-catalog/index.json` (machine-readable) + `asset-catalog/index.md` (human-readable). Re-runnable, deterministic.
- **D-06:** Catalogue carries **all three metadata tiers**: Aggregator (every meta.json field), Derived (magic-byte format detection, sprite-size buckets, etc.), Semantic cross-ref (`used_by: [object_id, script_id, room_id]` per asset). Reason: legacy source is frozen, drift cost is low.
- **D-07:** Asset catalogue preserves Phase 1 D-12 revision (Option A): sprite/background/font payloads carried as opaque BMP bytes. Records `{format: 'bmp' | 'unknown', byteLength: N}`. **No image decode in Phase 2.**
- **D-08:** `extracted/<src>/UNKNOWN-ACTIONS.md` resolution is a **CDOC-01 prerequisite**. For each unmapped Action_ID: either (a) port the entry from LateralGM source into `tools/extract-gmd/data/action-ids.json` and re-run extraction, or (b) document explicitly as DEFERRED with reason in `docs/extracted-engine/unknown-actions-status.md`.

#### Doc Structure & Depth (CDOC-01)
- **D-09:** Top-level organization of `docs/extracted-engine/` is **by engine subsystem**, one Markdown per subsystem. List LOCKED: `rendering.md`, `input.md`, `collision.md`, `animation.md`, `scene-room-model.md`, `save-load.md`, `audio.md`, `ui-and-menus.md`, `client-networking.md`, `admin-anti-port.md`, `unknown-actions-status.md`, `MATRIX.md`, `README.md`. Plus auto-generated `asset-catalog/` subtree.
- **D-10:** Reverse-engineering depth = **functional clusters with citation + key-script call-out**. Group by behavior; quote 5-30 line snippets inline; do NOT reverse every line.
- **D-11:** Authoring split: **hand-authored prose + auto-generated indices/cross-refs**. Subsystem narrative hand-authored; rosters/cross-ref tables emitted by tool.

#### Phaser-vs-PixiJS Decision (CDOC-03 + CDOC-04)
- **D-12:** `docs/extracted-engine/MATRIX.md` is a **paper-analysis matrix** with weighted scoring. No prototype spike unless hard-knockout surfaces.
- **D-13:** Hard-knockout rule: if Phaser 3 lacks first-class support and the workaround would be invasive, ADR flips to PixiJS. Otherwise default to Phaser per `research/STACK.md`.
- **D-14:** ADR lives at `docs/adr/0001-client-engine.md` (first ADR). Format: Status / Context / Decision / Consequences. Cites `MATRIX.md`.

#### Tooling
- **D-15:** `tools/asset-catalog/` is standalone Node CLI, not yet workspace package. Invocation: `pnpm tsx tools/asset-catalog/cli.ts <extracted-dir> <out-dir>`. Repo-level `pnpm catalog:all` runs both client and server.
- **D-16:** Catalogue output deterministic per Phase 1 D-15: sorted keys, 2-space JSON indent, LF, no timestamps, no absolute paths. Source enumeration order = extracted tree's directory order. Re-runnable, byte-identical.

### Claude's Discretion
- Subsystem MD list (D-09) is locked but additions permitted during planning if extraction surfaces a coherent subsystem not on this list.
- Functional-cluster grouping criteria (D-10) — left to doc-writer's judgment as long as every script and every event is reachable from at least one subsystem doc via auto-generated cross-ref index.
- Methodology for Phaser-vs-Pixi (within D-12/D-13/D-14): weighting scheme, row count, column count are this researcher/planner's call.

### Deferred Ideas (OUT OF SCOPE)
- **Prototype spike for Phaser-vs-Pixi.** Only triggered if D-13 hard-knockout fires.
- **Full per-script reverse engineering.** D-10 explicitly defers.
- **`docs/asset-pipeline/`** — Phase 6/7 deliverable.
- **Modernized admin UI design.** Phase 7 PAR-07.
- **Server engine documentation.** Phase 3.
- **Round-trip doc → schema → doc generators.** Premature; would couple Phase 2 doc format to Phase 4 code shape.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| CDOC-01 | `docs/extracted-engine/` documents every client game-engine feature: rendering, input, collision, animation, scene/room model, save/load, audio | §Subsystem-MD Authoring Workflow + §Functional Cluster Inventory below identify which scripts/objects/events feed each subsystem MD; §Hand-Authored + Autogen Hybrid Pattern locks the authoring/regen workflow per D-11; §Audio caveat — extraction emitted no `sounds/` tree, so `audio.md` is largely "no native audio assets in client" |
| CDOC-02 | Asset catalogue lists every sprite, sound, font, room with metadata | §`tools/asset-catalog` Architecture defines module split, semantic-cross-ref pass, determinism strategy; §Performance on 8307 Files validates approach scales |
| CDOC-03 | Feature-vs-engine matrix scores Phaser 3 vs PixiJS row-by-row | §MATRIX Methodology defines weighting + row generation; §Phaser-vs-PixiJS Feature Inventory enumerates rows from CDOC-01 subsystems; §Phaser 4 Caveat addresses the version question that emerged since STACK.md was written |
| CDOC-04 | ADR records final client engine choice with rationale tied directly to matrix | §ADR Format + Citation Discipline ensures rationale → matrix row mapping; §Hard-Knockout Watch List names the specific Phaser features most likely to flip the decision |
</phase_requirements>

## Summary

Phase 2 is a documentation phase, not a code phase. The research divides cleanly into three workstreams:

1. **`tools/asset-catalog` design.** A standalone Node TS CLI mirroring `tools/extract-gmd` conventions. The interesting design decision is the semantic-cross-ref pass (D-06 tier 3) which builds an `asset → consumers` index by grepping all 198 scripts and walking all 320 object event ASTs (`.dnd.json` files). At the actual scale of `extracted/client-5-8/` (8307 files, 854 sprites, 12 backgrounds, 320 objects, 16 rooms; total ~83 MB) this is a single-pass O(N) scan that completes in seconds — no performance concern.

2. **Subsystem-MD authoring.** Hand-authored prose with autogen-block-delimited rosters (per D-11 + Specifics #autogen-blocks). Pattern is structurally identical to Phase 1 D-19 (TOOLS.md as thin wiki wrapper). The same `tools/asset-catalog` binary that emits the asset catalogue also refreshes the autogen blocks inside subsystem MDs in place — single tool, one invocation.

3. **Phaser-vs-PixiJS matrix + ADR.** Paper analysis. The matrix's row generator is CDOC-01: each documented engine feature becomes one row. **Critical update: STACK.md's recommendation pre-dates Phaser 4.1.0 stable** (npm-verified `latest`=4.1.0 at research time). The matrix MUST score three columns (Phaser 3.90, Phaser 4.1, PixiJS 8.18), not two, and the ADR MUST address the "should we already be on v4" question — STACK.md flagged this as a decision-gate at Stage 6 start; CDOC-04 is the natural place to resolve it.

**Primary recommendation:** Build `tools/asset-catalog` as a single CLI with three subcommands (`catalog`, `cross-ref`, `regen-autogen`) all sharing one in-memory `Project` model. Author subsystem MDs with autogen blocks delimited by HTML comment markers. Resolve UNKNOWN-ACTIONS.md via path (a) — port LateralGM's action library entries 523/525 into `action-ids.json` and re-run extraction; the cost is hours, not days, and it's bounded (only two unique IDs across 27 sites). MATRIX.md adds a Phaser-4 column and the ADR explicitly chooses among three engines.

## Architectural Responsibility Map

Phase 2 is single-tier (offline tooling + documentation). The "tier" axis collapses to "where the artifact lives in the build pipeline":

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Read `extracted/<src>/**/meta.json` aggregation | Build-time / offline (`tools/asset-catalog`) | — | Mirrors Phase 1 `tools/extract-gmd` pattern; no runtime exposure |
| Derived-field computation (magic-byte detection, histograms) | Build-time / offline | — | Pure function of meta.json + opaque byteLength; no source-of-truth changes |
| Semantic cross-ref construction (asset → consumers) | Build-time / offline | — | Builds index from script grep + object event AST walk; one-shot at catalog time |
| Subsystem-MD prose | Authored artifact (`docs/extracted-engine/*.md`) | — | Human-curated narrative; cited but not generated |
| Subsystem-MD autogen blocks (rosters, cross-ref tables) | Build-time / offline → in-place doc edit | — | Same tool refreshes delimited blocks inside hand-authored MDs |
| Phaser-vs-Pixi matrix | Authored artifact (`docs/extracted-engine/MATRIX.md`) | Optional offline check (re-score script) | Per Specifics: weights captured as data so Phase 6 can re-score without re-reading prose |
| ADR | Authored artifact (`docs/adr/0001-client-engine.md`) | — | First ADR in repo; locks Phase 6 stack |

**Tier-correctness sanity check:** Nothing in Phase 2 ships runtime code. Hard rule #6 in CLAUDE.md ("Extract → document → rewrite") forbids new TypeScript outside `tools/`. The only new code is `tools/asset-catalog/`. The plan-checker should reject any task that proposes adding files under `apps/`, `packages/`, or `src/` outside the `tools/` tree.

## Standard Stack

### Core (for `tools/asset-catalog`)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Node | 22 LTS | Runtime | [VERIFIED: matches `tools/extract-gmd` per Phase 1 plan 01-01]. No native modules needed for catalog work — pure file IO + JSON. |
| TypeScript | 5.6.3 | Source | [VERIFIED: pinned by `tools/extract-gmd/package.json`]. Same pin keeps tools tree consistent. |
| tsx | 4.21.0 | Dev runner | [VERIFIED: pinned by `tools/extract-gmd/package.json`]. CLI invoked as `pnpm tsx tools/asset-catalog/cli.ts`. |
| vitest | 4.1.5 | Test runner | [VERIFIED: pinned by `tools/extract-gmd/package.json`]. Same `maxWorkers: 1` config (per Phase 1 plan 01-01 note). |
| `@types/node` | 25.6.0 | Type defs | [VERIFIED: pinned by `tools/extract-gmd/package.json`]. |

**No runtime dependencies needed.** `asset-catalog` reads JSON, computes derived fields, builds cross-refs by grepping script source + walking `.dnd.json` files, and writes JSON+Markdown. Everything is in stdlib.

### Supporting (decision matrix sources, not new deps)

| Reference | Version | Purpose | When to Use |
|-----------|---------|---------|-------------|
| Phaser | 3.90.0 ("Tsugumi") | One MATRIX.md column | [VERIFIED: `npm view phaser@3.90.0 version` returned 3.90.0 on 2026-05-02]. STACK.md's preferred engine. |
| Phaser | 4.1.0 ("Salusa") | One MATRIX.md column (NEW — see §Phaser 4 Caveat) | [VERIFIED: `npm view phaser dist-tags` shows `latest: 4.1.0` on 2026-05-02]. STACK.md flagged as "re-evaluate at Stage 6 start"; CDOC-04 is the right gate. |
| PixiJS | 8.18.1 | One MATRIX.md column | [VERIFIED: `npm view pixi.js version` returned 8.18.1 on 2026-05-02]. STACK.md's fallback. |
| `@pixi/tilemap` | 5.0.2 | PixiJS tilemap dependency for room rendering | [VERIFIED: `npm view @pixi/tilemap version` returned 5.0.2 on 2026-05-02]. Required if MATRIX flips to PixiJS — Pixi has no native tilemap. |
| `howler` | 2.2.4 | PixiJS audio dependency | [VERIFIED: `npm view howler version` returned 2.2.4]. Required if MATRIX flips to PixiJS — Pixi has no audio. (Caveat: client extraction has NO sounds tree, so audio support is low-weight.) |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| TS Node CLI | Python script | Project-wide TS-everywhere convention (PROJECT.md). Tools/extract-gmd is TS. Mixing languages costs CI complexity for zero benefit. |
| Single CLI with subcommands | Multiple separate CLIs | Three workflows (catalog, cross-ref, regen-autogen) share the same in-memory `Project` model. Subcommands let one parse pass serve all three; separate CLIs would re-parse. |
| HTML-comment autogen markers (`<!-- AUTOGEN:scripts:start -->`) | Triple-fenced code block with marker | HTML comments don't render in any markdown viewer (clean reading); fenced blocks render but pollute prose flow. **Recommend HTML comments per Specifics line "wrapped in delimited blocks (e.g. `<!-- AUTOGEN:scripts:start -->`)".** |
| YAML front-matter for `mvp:` tag | Fenced metadata block / TOML sidecar | Front-matter is the GitHub/grep-friendly canonical pattern. **Recommend YAML front-matter per §`mvp:` Tag Format.** |
| Path (a) — port LateralGM IDs 523/525 | Path (b) — defer to `unknown-actions-status.md` | Path (a) cost: ~1-2 hours (two action entries; LateralGM source is open). Path (a) benefit: zero `UNKNOWN ACTION_ID` markers polluting `Draw.gml` files for objects 0034/0118/0325/0359/0384/etc. **Recommend (a) for both 523 and 525**; total 27 sites get clean GML. |

**Installation:**
```bash
mkdir -p tools/asset-catalog/{src,tests,data}
cd tools/asset-catalog
# package.json bootstrapped with same shape as tools/extract-gmd/package.json
# (no production dependencies; devDependencies = typescript, tsx, vitest, @types/node)
```

**Version verification:** All five primary tooling pins are inherited verbatim from `tools/extract-gmd/package.json` (verified by Read on 2026-05-02). The three MATRIX-column pins were verified via `npm view` on 2026-05-02.

## Architecture Patterns

### System Architecture Diagram

```
                       Phase 1 outputs
                              │
                              ▼
       extracted/client-5-8/  (read-only input)
        ├─ settings.json      ─┐
        ├─ sprites/<NN>-<n>/   │
        │   ├─ meta.json       │   loaded by
        │   └─ frames/*.bmp    │   Stage A
        ├─ backgrounds/...     ─┤
        ├─ scripts/<NN>-<n>.gml│
        ├─ objects/<NN>-<n>/   │
        │   ├─ meta.json       │
        │   └─ events/         │
        │       ├─ *.gml       │   loaded by
        │       └─ *.dnd.json ─┘   Stage A + B
        ├─ rooms/<NN>-<n>/...
        ├─ datafiles/...
        └─ UNKNOWN-ACTIONS.md  ─► Stage Z (D-08)

                              │
                              ▼
              ┌───────────────────────────────────┐
              │ tools/asset-catalog (one binary)   │
              │                                    │
              │  Stage A — Aggregator pass         │
              │     load every meta.json into a    │
              │     typed Project model            │
              │                                    │
              │  Stage B — Derived pass            │
              │     magic-byte format detection,   │
              │     sprite-size buckets,           │
              │     room-density histograms        │
              │                                    │
              │  Stage C — Semantic cross-ref pass │
              │     for each sprite_id, scan:      │
              │       - all object meta.spriteId   │
              │       - all object meta.maskId     │
              │       - all script .gml regex      │
              │         (sprite_*, draw_*)         │
              │       - all room instance lookups  │
              │     populate `used_by[]` per asset │
              │                                    │
              │  Stage D — Emit                    │
              │     index.json (machine, sorted)   │
              │     index.md   (human, table-fmt)  │
              │                                    │
              │  Stage E — Autogen-block refresh   │
              │     for each docs/extracted-engine │
              │       /*.md, find AUTOGEN markers  │
              │       and rewrite contents in place│
              └────────┬───────────────────────────┘
                       │
                       ▼
       docs/extracted-engine/
        ├─ asset-catalog/
        │   ├─ index.json   (deterministic, sorted)
        │   └─ index.md     (rendered tables)
        ├─ rendering.md     ┐  hand-authored prose
        ├─ input.md         │  + AUTOGEN-block sections
        ├─ collision.md     │  refreshed by Stage E
        ├─ animation.md     │
        ├─ scene-room-model.md  (covers room/scene)
        ├─ save-load.md
        ├─ audio.md             (mostly "absent — see CAVEAT")
        ├─ ui-and-menus.md  (per D-01)
        ├─ client-networking.md (39dll usage from client side)
        ├─ admin-anti-port.md   (per D-02)
        ├─ unknown-actions-status.md
        ├─ MATRIX.md            ← Phaser3 / Phaser4 / Pixi8 columns
        └─ README.md            ← task-keyed jump table

       docs/adr/
        └─ 0001-client-engine.md  ← cites MATRIX.md row IDs
```

The "data flow direction" is left-to-right: Phase 1 outputs → tool → docs. Subsystem MDs are authored first, then refreshed by Stage E to keep autogen blocks current with the catalog.

### Recommended Project Structure (tools/asset-catalog)

```
tools/asset-catalog/
├── cli.ts                           # CLI dispatcher (mirrors tools/extract-gmd/cli.ts)
├── package.json                     # No runtime deps; devDeps mirror extract-gmd
├── tsconfig.json                    # Same as extract-gmd/tsconfig.json
├── vitest.config.ts                 # maxWorkers: 1 (Phase 1 convention)
├── README.md                        # Thin wrapper per D-19 pattern
├── src/
│   ├── load.ts                      # Stage A: load all meta.json into Project model
│   ├── derive.ts                    # Stage B: magic bytes, buckets, histograms
│   ├── crossref.ts                  # Stage C: build asset→consumers index
│   ├── emit/
│   │   ├── index-json.ts            # Stage D.1: deterministic JSON
│   │   ├── index-md.ts              # Stage D.2: rendered Markdown
│   │   └── autogen-blocks.ts        # Stage E: refresh AUTOGEN markers in *.md
│   ├── grep.ts                      # Helper: GML-aware tokenization for crossref
│   └── types.ts                     # Catalog model (re-exports from extract-gmd/types)
└── tests/
    ├── load.test.ts                 # fixture: synthetic extracted/ tree
    ├── derive.test.ts
    ├── crossref.test.ts             # critical — cross-ref correctness
    ├── emit-determinism.test.ts     # re-run produces byte-identical output
    └── fixtures/                    # tiny synthetic extracted/ tree
        └── tiny/{sprites,scripts,objects}/...
```

**Rationale for split:**
- `load.ts` is the single source of "what's in the extracted tree?" — separated from derive/crossref so all three downstream passes consume the same in-memory shape.
- `crossref.ts` is the most expensive and most important module. Isolated for unit-test focus.
- `emit/autogen-blocks.ts` is structurally distinct from JSON/MD emit — it edits OTHER files (subsystem MDs in `docs/extracted-engine/`), so isolation surfaces the side-effect clearly.
- `grep.ts` exists because GML grep is not naive substring — `sprite_index = NaviStandD` is a reference to sprite "NaviStandD" but `// NaviStandD is broken` in a comment is not. See §Cross-Ref Tokenization Discipline.

### Pattern 1: Three-Tier Catalog Model (per D-06)

**What:** Each catalogued asset carries three layered field groups. Tier 1 fields come straight from the source `meta.json`. Tier 2 fields are deterministic functions of tier 1 + opaque payload bytes. Tier 3 fields are the result of cross-referencing the asset against every consumer in the project.

**When to use:** Catalogs where the source data is frozen (D-06 reasoning) and downstream consumers need richer queryability than raw aggregation.

**Example:**
```typescript
// tools/asset-catalog/src/types.ts
export interface CatalogedSprite {
  // Tier 1: aggregator (verbatim from extracted/<src>/sprites/<NN>/meta.json)
  id: number;
  name: string;
  blockVersion: number;
  width: number;
  height: number;
  bboxLeft: number; bboxRight: number; bboxTop: number; bboxBottom: number;
  bboxMode: number;
  precise: boolean;
  transparent: boolean;
  smoothEdges: boolean;
  preload: boolean;
  originX: number;
  originY: number;
  // (frame count from filesystem read of frames/ dir, not in meta.json)
  frameCount: number;

  // Tier 2: derived
  imageFormat: 'bmp' | 'unknown';      // magic-byte detection on first frame
  imageByteLength: number;             // sum across frames
  sizeBucket: 'tiny' | 'small' | 'med' | 'large';  // by width*height
  isAnimated: boolean;                  // frameCount > 1

  // Tier 3: semantic cross-ref
  used_by: {
    objects: number[];                  // GmObject.id where spriteId === this.id
    masks_for: number[];                // GmObject.id where maskId === this.id
    scripts: number[];                  // Script.id whose .gml mentions this name
    rooms: number[];                    // Room.id whose instances reference an object whose spriteId === this.id (transitive)
  };
}
```

### Pattern 2: Hand-Authored + Autogen Hybrid (per D-11 + Specifics)

**What:** Subsystem MDs are hand-authored. Inside each, marked regions are owned by `tools/asset-catalog` Stage E and rewritten in place when the tool runs.

**When to use:** Documentation where some content is judgment-dependent (prose) and some is mechanically derivable (rosters, cross-refs) and the two need to live in one navigable artifact.

**Example:**
```markdown
<!-- inside docs/extracted-engine/rendering.md -->

## Drawing model

[hand-authored prose about how BNO uses draw events,
 the 0269-draw_rqbar.gml script pattern, etc.]

## Scripts referenced by this subsystem

<!-- AUTOGEN:scripts:start hash=sha256(input)... -->
| Script ID | Name | Used in objects | Notes |
|-----------|------|-----------------|-------|
| 0002 | script_drawlines | 0045, 0223 | drawn-text rendering primitive |
| 0269 | draw_rqbar | 0464, 0480 | request-bar draw helper |
| ...
<!-- AUTOGEN:scripts:end -->

## Objects with custom Draw events

<!-- AUTOGEN:draw-objects:start hash=... -->
| Object ID | Name | Sprite | Notes |
|-----------|------|--------|-------|
| 0034 | title | 0099-Logo | uses action 523 (set font) |
| ...
<!-- AUTOGEN:draw-objects:end -->
```

**Tool contract:** When `pnpm tsx tools/asset-catalog/cli.ts regen-autogen docs/extracted-engine/`:
1. For each `*.md`, find every `<!-- AUTOGEN:NAME:start ... -->` ... `<!-- AUTOGEN:NAME:end -->` pair.
2. Compute the new content for that block (lookup table by NAME → generator function).
3. Diff against current; if different, splice in. If identical, skip (so the file's mtime doesn't churn).
4. The optional `hash=...` attribute lets a CI check verify the autogen block matches what the current code WOULD produce — catches manual edits that bypassed the tool.

### Pattern 3: Deterministic Catalog Output (per D-16, mirrors Phase 1 D-15)

**What:** Re-running the tool against the same input produces byte-identical output files. Achieved by sorting all keys, fixing JSON indentation, using LF only, omitting timestamps and absolute paths.

**Example:**
```typescript
// tools/asset-catalog/src/emit/index-json.ts
import { writeFileSync } from 'node:fs';

/** Recursively sort object keys (arrays preserved in source order). */
function sortKeysDeep<T>(value: T): T {
  if (Array.isArray(value)) return value.map(sortKeysDeep) as unknown as T;
  if (value && typeof value === 'object') {
    const sorted: Record<string, unknown> = {};
    for (const k of Object.keys(value as object).sort()) {
      sorted[k] = sortKeysDeep((value as Record<string, unknown>)[k]);
    }
    return sorted as T;
  }
  return value;
}

export function emitIndexJson(outPath: string, catalog: Catalog): void {
  const sorted = sortKeysDeep(catalog);
  // 2-space indent, trailing newline, LF only.
  const json = JSON.stringify(sorted, null, 2) + '\n';
  // Force LF on Windows hosts (matches Phase 1 D-15).
  writeFileSync(outPath, json.replace(/\r\n/g, '\n'), { encoding: 'utf-8' });
}
```

### Anti-Patterns to Avoid

- **Naive substring grep for cross-ref.** `if (script.code.includes(spriteName))` will match comments, partial substrings (`Hexport` matches `HexportIn`/`HexportOut`/`HexportMask` — see object 0042-player Step.gml), and unrelated identifiers. Use word-boundary regex with `\b` and skip `//`-comment lines. See §Cross-Ref Tokenization Discipline.
- **Walking script ASTs to extract cross-refs.** GML 5.x has no production-grade JS parser. Substring/regex on tokenized GML is the appropriate level of effort for this tier.
- **Per-asset background re-extraction.** Tempting "re-run extraction at catalog time so tier 2 always reflects current source" — instead trust the input. Phase 1 is frozen. If extraction changes, re-run extraction first, then catalog.
- **Building catalog in MD → consuming-tool dependency direction.** Subsystem MDs read FROM the catalog (via autogen blocks). The catalog must NOT read from subsystem MDs (would create a cycle). MATRIX.md is the only exception — it's authored, not auto.
- **Hand-rolling MATRIX.md row generation.** Each row corresponds to a documented CDOC-01 feature. The list of rows MUST come from a single canonical "feature list" file (recommend `docs/extracted-engine/MATRIX-rows.json` or a YAML sidecar) so MATRIX.md and the ADR cite stable row IDs. See §MATRIX Methodology.
- **Treating UNKNOWN-ACTIONS.md as "complete" because Phase 1 emitted it.** Per D-08 it's a CDOC-01 prerequisite — must be either resolved (path a) or explicitly deferred with reason (path b) before docs are signed off.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| GML parsing for cross-ref | A GML lexer/parser | Word-boundary regex + comment-line skip | GML 5.x has no JS parser; full parse is weeks of work and unnecessary at this tier of analysis (D-10 explicitly defers full reverse). |
| ADR template | Free-form ADR | Michael Nygard format (Status / Context / Decision / Consequences) | D-14 locks this format. It's the standard ADR shape. Don't invent. |
| MATRIX scoring rubric | Custom 1-5 scale prose | Capability-grade enum (`Native` / `Plugin` / `Manual` / `Hard`) per PITFALLS D3 | Already specified in PITFALLS D3 ("for each documented client engine feature... grade Phaser 3 and PixiJS as Native / Plugin / Manual / Hard"). Mirror the project's own pre-locked methodology. |
| YAML front-matter parser | YAML library dependency | Read first 0-N lines, parse `key: value` lines until `---` | `mvp: yes/no` is two pairs max. A 5-line regex parser is sufficient. Avoid pulling in `js-yaml` (~50 KB) for this. |
| Markdown table renderer | Mustache / Handlebars | Hand-string-concat with `'| ' + row.join(' | ') + ' |'` | Tables are tiny. Template engines pay back at >10 templates; we have ~13 subsystem MDs. |
| Diff/patch for autogen-block in-place edit | `diff` / `patch` library | `String.replace()` between AUTOGEN markers | Single-block, single-file replacement is one regex. Adding a diff lib is overkill. |
| LateralGM Action_ID research | Re-derive from .gmd hex | Read LateralGM `.lgl` parser tables (already ported in Phase 1 plan 01-03) | Plan 01-03 already ported `.lgl` to `action-ids.json`; adding entries 523/525 is just appending two records and a one-shot `pnpm extract:all` re-run. |

**Key insight:** This phase is anti-engineered — it's an aggregator + a documentation set. The temptation to build a "documentation framework" must be resisted; the value lives in the prose and the cross-ref data, not in the tool's complexity.

## Runtime State Inventory

> Phase 2 is documentation-only. The "runtime state" question reduces to: are there any frozen artifacts whose semantics could drift if Phase 1 re-extracts in the future?

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — Phase 2 reads `extracted/client-5-8/` (committed Phase 1 output) and writes `docs/extracted-engine/` + `docs/adr/` (new). Both trees are git-versioned. | None |
| Live service config | None — no services run in this phase. | None |
| OS-registered state | None — no OS registrations. | None |
| Secrets/env vars | None — pure read/write of repo files. | None |
| Build artifacts | `extracted/client-5-8/MANIFEST.sha256` is the upstream input's freshness marker. If the catalog's tier-1 fields ever drift from the source meta.json, re-running `tools/asset-catalog` regenerates everything from scratch (no migration). | Document in `tools/asset-catalog/README.md`: "If `extracted/<src>/MANIFEST.sha256` changes, run `pnpm catalog:all` and commit the diff." |

**Cross-ref drift sub-question:** if Phase 1 ever re-extracts and a sprite gets renamed (sanitized differently or a fixture-writer regression renames it), the cross-ref index is stale until catalog re-runs. Mitigation: the catalog tool can verify input freshness by reading `extracted/<src>/MANIFEST.sha256` and embedding that hash in `index.json` so a Phase 6 consumer detects "this catalog is stale relative to what's in the tree." This is OPTIONAL; D-06's "legacy source is frozen" reasoning makes drift unlikely.

## Common Pitfalls

### Pitfall 1: Naive cross-ref grep matches identifiers as substrings

**What goes wrong:** `script.code.includes('Hexport')` returns true for `Hexport`, `HexportIn`, `HexportOut`, `HexportMask` — four distinct sprite names with overlapping prefixes (verified in `objects/0042-player/events/Step.gml`).

**Why it happens:** GML identifiers can prefix-overlap freely; substring grep doesn't respect token boundaries.

**How to avoid:** Use word-boundary regex (`new RegExp('\\b' + escapeRegex(name) + '\\b', 'g')`). For `name`s containing characters that are valid in identifiers but also regex metachars (rare in this corpus — names are mostly ASCII alphanumeric), escape via `name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`. Skip lines that begin with `//` (single-line GML comments).

**Warning signs:** A sprite reports `used_by.scripts: [n]` where n includes a script that grep'd a longer-named sprite. Test fixture: synthetic project with sprites `Foo`, `FooBar`, `FooBaz` and a script that mentions only `FooBar` — assert that `Foo`'s `used_by.scripts` does NOT include that script.

### Pitfall 2: Block-comment grep state machine

**What goes wrong:** GML supports `/* ... */` block comments. Word-boundary regex matches inside them.

**Why it happens:** Single-line skip (`if (line.startsWith('//')) continue`) misses block comments which span multiple lines.

**How to avoid:** Pre-process script source to strip `/* ... */` blocks before regex grep. One pass: `code.replace(/\/\*[\s\S]*?\*\//g, '')`. This is a known-good GML approach because GML 5.x doesn't support nested block comments (per `decomp/wiki/06-gml-syntax-5x.md`).

**Warning signs:** A sprite reports being used by an object whose only mention is in a commented-out block (the `iv_itemdb` plaintext-credentials commented-out block from STATE.md plan 01-07 forensic finding is a real example — it would falsely cross-ref hardcoded usernames as "sprites" if cross-ref grep were untyped).

### Pitfall 3: DnD action argument values are strings, not typed

**What goes wrong:** Cross-ref looking for sprite IDs in DnD action arguments treats arg `"65"` (Action_ID 102 = "Set sprite") as the literal string `"65"`, not the integer sprite ID 65.

**Why it happens:** Per Phase 1 plan 01-03 W3 LOCKED: DnD `readArgValue` reads ALL arg values as 4-byte length-prefixed strings (LateralGM precedent). Numeric/resource conversion is downstream.

**How to avoid:** When walking `events/*.dnd.json`, look at the action's `argTypes` array (`0`=Real, `1`=String, `4`=Resource sprite, `5`=Resource sound, `6`=Resource background, `7`=Resource path, `8`=Resource script, `9`=Resource object, `10`=Resource room, `11`=Resource font, `12`=Resource timeline, etc.) and Number()-convert string values whose argType is Real or Resource-*. The action-IDs.json already provides argType context per action.

**Warning signs:** Cross-ref index shows zero `used_by.objects` for almost every sprite (because DnD references are strings and weren't matched against numeric IDs).

### Pitfall 4: `image_index` / `sprite_index` as variables, not direct sprite assignments

**What goes wrong:** Object 0042-player's Step.gml uses `if(sprite_index != Hexport && sprite_index != HexportIn && ...)`. Cross-ref grep correctly identifies `Hexport`, `HexportIn` etc. as sprite references. But the assignment `sprite_index = NaviMask` should ALSO be detected.

**Why it happens:** Multiple GML idioms reference sprites: direct assignment (`sprite_index = X`), comparison (`sprite_index == X`, `sprite_index != X`), and function args (`sprite_add(X, ...)`, `mask_index = X`).

**How to avoid:** Document in `crossref.ts` the canonical reference patterns. Test against object 0042-player Step.gml's `Hexport`/`HexportIn`/`HexportOut`/`HexportMask`/`NaviMask` — assert all five sprites end up cross-ref'd to player object.

**Warning signs:** Sprites that are visually obvious in subsystem prose ("the Navi mask is used by the player object") report `used_by.objects: []`.

### Pitfall 5: MATRIX weights as prose vs data

**What goes wrong:** MATRIX.md authors weights as in-text fractions ("rendering: 30%, audio: 5%..."). Phase 6 wants to re-score after a CDOC-01 update; engineer can't programmatically re-sum because weights are in prose.

**Why it happens:** Markdown tables are easy to author with embedded weights; the discipline of "weights as data" is extra friction.

**How to avoid:** Per Specifics: capture weights in a sidecar file (`docs/extracted-engine/MATRIX-weights.json` or YAML front-matter at the top of MATRIX.md). Render the visible weight column from that data. A trivial regenerator (or just a verifier — `node tools/asset-catalog/scripts/check-matrix.js`) confirms the rendered totals match the data.

**Warning signs:** A weight changes; engineer must hand-edit every row's weighted-total cell.

### Pitfall 6: ADR cites prose instead of MATRIX rows

**What goes wrong:** ADR Decision section says "Phaser 3 wins because it has better tilemap support and audio integration." Phase 6 reader can't trace this back to specific MATRIX rows. If MATRIX is updated to reflect a Phaser 4 audio change, the ADR is silently stale.

**Why it happens:** ADR authoring is text-first, not citation-first.

**How to avoid:** Each row in MATRIX.md gets a stable row ID (e.g. `MX-RENDER-01`, `MX-AUDIO-03`). The ADR Decision and Consequences sections cite specific row IDs. A linter (`tools/asset-catalog/scripts/verify-adr-citations.ts`) confirms every cited row ID exists in MATRIX.md.

**Warning signs:** Phase 6 reader asks "why was Phaser chosen?" and the answer requires re-reading MATRIX.md plus the ADR side-by-side.

### Pitfall 7: D-08 unresolved at sign-off

**What goes wrong:** Phase 2 closes with `extracted/client-5-8/UNKNOWN-ACTIONS.md` still listing 27 sites and `unknown-actions-status.md` doesn't exist. CDOC-01 is "complete" but objects' Draw events still emit `// UNKNOWN ACTION_ID=523; see .dnd.json` markers — a Phase 6 reader can't read what those events do.

**Why it happens:** Forgot to gate sign-off on D-08 prerequisite. Or chose path (b) without writing `unknown-actions-status.md`.

**How to avoid:** Make D-08 resolution a Wave 0 task in the plan. Concretely: port LateralGM action library entries 523 and 525 into `tools/extract-gmd/data/action-ids.json` (path a), re-run `pnpm extract:all`, verify `extracted/client-5-8/UNKNOWN-ACTIONS.md` is empty (or absent — the file is only emitted iff entries collected). Validation gate before any subsystem MD draft.

**Warning signs:** `grep -r "UNKNOWN ACTION_ID" extracted/client-5-8/` returns non-zero when CDOC-01 is being signed off.

### Pitfall 8: "extraction emitted no `sounds/` tree" not surfaced in audio.md

**What goes wrong:** `audio.md` is drafted as if BNO has many sound assets to catalog. In reality, Phase 1's plan 01-07 produced no `sounds/` directory in `extracted/client-5-8/` (verified by Bash `ls`). Either client `.gmd` carries no embedded audio, or audio is loaded externally via `external_define` + 39dll's MIDI patterns. `audio.md` MUST surface this.

**Why it happens:** Documenting from a subsystem-checklist mindset rather than from the actual extracted artifact.

**How to avoid:** Wave 0 or first authoring task: enumerate which CDOC-01 subsystem MDs have NO direct asset support in extraction. For client, this is at minimum `audio.md` (no sounds), and potentially `animation.md` / `save-load.md` (no datafiles for save format — datafiles dir has 3 entries; verify content). `audio.md` should be renamed in spirit to "audio-handling" — the script `0269-draw_rqbar.gml` may be UI but the broader audio question is "how does BNO play sound at all?" — likely via 39dll's `playmidi` or `sound_play` pointing at file paths embedded in scripts. Cross-ref `script` grep for `sound_play|playmidi|external_define.*sound` answers it.

**Warning signs:** `audio.md` sentence "BNO has 47 sound effects" with no `extracted/client-5-8/sounds/` directory to back it up.

### Pitfall 9: Phaser 4 ignored (STACK.md is stale on this point)

**What goes wrong:** MATRIX.md scores Phaser 3.90 vs PixiJS 8.18, ADR locks Phaser 3. Phase 6 starts in N weeks; engineer reads release notes, asks "why didn't we evaluate Phaser 4?" Decision is silently re-opened mid-Phase-6.

**Why it happens:** STACK.md (researched 2026-05-01) recommends Phaser 3 with a noted "re-evaluate at Stage 6 start" gate for Phaser 4. CDOC-04 is the authoritative engine-locking ADR — that's where Phaser 4 should be evaluated, not Stage 6.

**How to avoid:** MATRIX.md adds a third column (Phaser 4.1.0). ADR explicitly addresses Phaser 4 with at minimum: "Considered Phaser 4 because it became latest stable on 2026-04. Rejected/accepted because [matrix row(s)]." See §Phaser 4 Caveat.

**Warning signs:** Phaser 4 not mentioned anywhere in MATRIX.md or the ADR.

## Code Examples

### Example: Loading the extracted Project model

```typescript
// tools/asset-catalog/src/load.ts
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import type { Sprite, Background, Script, GmObject, Room, Datafile } from '../../extract-gmd/src/types.js';

export interface ExtractedProject {
  rootDir: string;
  manifestSha256: string;
  settings: Record<string, unknown>;
  sprites: Array<Sprite & { dir: string; frameCount: number; firstFrameMagic: Buffer | null }>;
  backgrounds: Array<Background & { dir: string; imageMagic: Buffer | null }>;
  scripts: Array<Script & { filePath: string }>;
  objects: Array<GmObject & { dir: string; eventFiles: string[] }>;
  rooms: Array<Room & { dir: string }>;
  datafiles: Datafile[];
}

export function loadExtracted(rootDir: string): ExtractedProject {
  // Settings
  const settings = JSON.parse(readFileSync(join(rootDir, 'settings.json'), 'utf-8'));

  // Manifest
  const manifestSha256 = readFileSync(join(rootDir, 'MANIFEST.sha256'), 'utf-8');

  // Sprites (sorted by directory name = numeric prefix)
  const spritesDir = join(rootDir, 'sprites');
  const sprites = readdirSync(spritesDir).sort().map(dirName => {
    const dir = join(spritesDir, dirName);
    const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
    const framesDir = join(dir, 'frames');
    const frameFiles = statSync(framesDir).isDirectory()
      ? readdirSync(framesDir).filter(f => /^img_\d+\.(bmp|bin)$/.test(f)).sort()
      : [];
    const firstFrameMagic = frameFiles.length > 0
      ? readFileSync(join(framesDir, frameFiles[0])).subarray(0, 2)
      : null;
    return { ...meta, dir: dirName, frameCount: frameFiles.length, firstFrameMagic };
  });

  // ... similar for backgrounds, scripts, objects, rooms, datafiles ...

  return { rootDir, manifestSha256, settings, sprites, backgrounds: [], scripts: [], objects: [], rooms: [], datafiles: [] };
}
```

### Example: Cross-ref for a single sprite

```typescript
// tools/asset-catalog/src/crossref.ts
import type { ExtractedProject } from './load.js';

const COMMENT_LINE_RE = /^\s*\/\//;
const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export interface SpriteCrossRef {
  objects: number[];     // GmObject.id where spriteId === sprite.id
  masks_for: number[];   // GmObject.id where maskId === sprite.id
  scripts: number[];     // Script.id whose .gml mentions name (word-boundary, comment-stripped)
  rooms: number[];       // Room.id whose instances reference an object whose spriteId === sprite.id
}

export function crossRefSprite(project: ExtractedProject, sprite: { id: number; name: string }): SpriteCrossRef {
  const ref: SpriteCrossRef = { objects: [], masks_for: [], scripts: [], rooms: [] };

  // Objects directly using this sprite
  for (const obj of project.objects) {
    if (obj.spriteId === sprite.id) ref.objects.push(obj.id);
    if (obj.maskId === sprite.id) ref.masks_for.push(obj.id);
  }

  // Scripts mentioning this sprite by name (word-boundary, comment-stripped)
  const wordBoundary = new RegExp('\\b' + escapeRegex(sprite.name) + '\\b');
  for (const script of project.scripts) {
    let stripped = script.code.replace(BLOCK_COMMENT_RE, '');
    const lines = stripped.split('\n').filter(l => !COMMENT_LINE_RE.test(l));
    if (wordBoundary.test(lines.join('\n'))) ref.scripts.push(script.id);
  }

  // Rooms whose instances reference an object that uses this sprite (transitive via ref.objects)
  const objectIds = new Set(ref.objects);
  for (const room of project.rooms) {
    // room.instances loaded separately from instances.json
    for (const inst of (room as { instances?: Array<{ objectId: number }> }).instances ?? []) {
      if (objectIds.has(inst.objectId)) {
        if (!ref.rooms.includes(room.id)) ref.rooms.push(room.id);
        break;
      }
    }
  }

  return ref;
}
```

### Example: YAML front-matter `mvp:` tag (per D-04)

```markdown
---
mvp: yes
subsystem: input
related-scripts: [0006, 0044]
related-objects: [0042]
---

# Player movement input

WASD/arrow-key handling is in `objects/0042-player/events/Keyboard-37.gml` (left arrow), `Keyboard-38.gml` (up), etc. Step event integrates `hs`/`vs` per-frame.
...
```

**Why YAML front-matter:**
- Native to GitHub markdown rendering — front-matter is hidden from readers but visible to grep/parse tools.
- One-liner extraction: `awk '/^---$/{f++;next} f==1' FILE | grep '^mvp:'`.
- Trivially typed: `mvp: yes | no` is a one-character delta from `mvp: yes`.
- Established pattern for documentation metadata across the JS ecosystem (Astro, Docusaurus, Hugo, Jekyll all use it).

**Why NOT a fenced metadata block** (e.g. ` ```meta\nmvp: yes\n``` `): renders visibly to readers, polluting the prose. Worse for the documentation-first audience than for grep tools.

**Why NOT a sidecar TOML file** (e.g. `rendering.md.meta.toml`): keeping metadata in a separate file means it can drift. Front-matter co-location is the right discipline.

### Example: Action_ID 523/525 resolution (D-08 path (a))

Inspecting `extracted/client-5-8/objects/0034-title/events/Draw.dnd.json`:
```json
{ "actionId": 523, "argTypes": [1,0,0,3,3,3,0,0],
  "argValues": ["OCRA","20","16711680","0","0","0"] }
```
Argument shape (font name: string, size: real, color: real, halign: real, valign: real, antialias: real) maps to GameMaker `draw_set_font(font_X) + draw_set_color(c) + draw_set_halign(ha) + draw_set_valign(va)`. This is the LateralGM "Set font" action.

Inspecting `extracted/client-5-8/objects/0045-commandob/events/Draw.dnd.json`:
```json
{ "actionId": 525, "argTypes": [15,...], "argValues": ["Fixedsys,12,16777215,0,0,0,0"] }
```
Single composite arg "name,size,color,bold,italic,halign,valign". This is the "Set font (combined)" action.

Resolution: add to `tools/extract-gmd/data/action-ids.json`:
```json
{ "id": 523, "name": "set_font", "argCount": 6,
  "argKinds": [{"kind":"string","name":"font"},{"kind":"real","name":"size"},
               {"kind":"real","name":"color"},{"kind":"real","name":"halign"},
               {"kind":"real","name":"valign"},{"kind":"real","name":"antialias"}],
  "template": "draw_set_font(@1) draw_set_color(@3) draw_set_halign(@4) draw_set_valign(@5)" }
```

(Exact LateralGM `.lgl` IDs and templates must be sourced from LateralGM 1.8.234 stock; see `tools/extract-gmd/data/README.md` for the porter procedure established in plan 01-03.)

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Phaser 3.85 | Phaser 3.90 "Tsugumi" | 2026-Q1 (per STACK.md) | Latest stable in the v3 line; STACK.md's preferred MVP engine. |
| Phaser 3 as "current" | Phaser 4.1 "Salusa" as `latest` on npm | 2026-04 release | [VERIFIED: `npm view phaser dist-tags` shows `latest: 4.1.0` on 2026-05-02]. STACK.md flagged this as a future re-evaluation; CDOC-04 must address. See §Phaser 4 Caveat. |
| PixiJS 7 | PixiJS 8.18.1 | 2026-04-14 (per STACK.md) | v8 is current; v7 is legacy. `@pixi/tilemap@5.0.2` requires Pixi v8+. |
| Lucia auth, Socket.IO, raw `ws`-only servers | Better-Auth, Colyseus | 2025/2026 | Not relevant to Phase 2 directly; mentioned because MATRIX must NOT score against deprecated stacks. |

**Deprecated/outdated in scope of this phase:**
- Phaser CE / Phaser 2 — EOL, do not score in MATRIX.
- "Phaser is GameMaker-like" cliché — accurate but UNVERIFIED at the API level until MATRIX rows are filled. Don't write it as ADR rationale without a row to cite.

## `tools/asset-catalog` Architecture

### Module Split (per D-05/D-15 conventions of `tools/extract-gmd`)

| Module | Responsibility | LOC estimate | Test coverage target |
|--------|---------------|--------------|----------------------|
| `cli.ts` | Subcommand dispatcher: `catalog` / `cross-ref` / `regen-autogen` (or one default that does all three) | 60-100 | Smoke (CLI exit codes 0/1/2 mirror extract-gmd) |
| `src/load.ts` | Read all `meta.json` + script source + event files into `ExtractedProject` model | 150-200 | High — fixture-based; test missing dirs (`sounds/` absent) gracefully |
| `src/derive.ts` | Magic-byte detection, sprite-size buckets, format histograms, animation flag | 100-150 | High — pure functions, easy to test |
| `src/crossref.ts` | Build `used_by` index per asset (sprites, backgrounds, scripts, sounds, fonts, rooms) | 200-300 | **Critical** — see Pitfalls 1-4. Test fixtures must include identifier-prefix collisions, block comments, DnD numeric ref types. |
| `src/grep.ts` | GML-aware tokenization helpers | 50-80 | High — pure helpers, exhaustive edge cases |
| `src/emit/index-json.ts` | Deterministic JSON emit | 30-50 | Round-trip determinism (re-run = byte-identical) |
| `src/emit/index-md.ts` | Human-readable Markdown emit (tables) | 80-120 | Snapshot test against fixture |
| `src/emit/autogen-blocks.ts` | In-place rewrite of `<!-- AUTOGEN:... -->` blocks in `docs/extracted-engine/*.md` | 80-120 | Idempotency test (re-run on already-up-to-date file = 0 changes) |
| `src/types.ts` | Catalog model — re-exports from `tools/extract-gmd/src/types.ts` plus catalog-specific extensions (e.g. `CatalogedSprite`) | 80-120 | Type-only; covered by tsc --noEmit |

**Total estimated LOC: 850-1320 implementation + ~600-900 tests. Comfortably one phase's work.**

### CLI Shape (mirror `tools/extract-gmd/cli.ts`)

```bash
# Default: full pipeline (catalog → cross-ref → emit → regen-autogen)
pnpm tsx tools/asset-catalog/cli.ts <extracted-dir> <docs-out-dir>

# Subcommand mode (for CI sub-checks):
pnpm tsx tools/asset-catalog/cli.ts catalog <extracted-dir> <out-dir>     # emit index.json + index.md only
pnpm tsx tools/asset-catalog/cli.ts cross-ref <extracted-dir> <out-dir>   # rebuild semantic-tier only
pnpm tsx tools/asset-catalog/cli.ts regen-autogen <docs-dir>              # refresh AUTOGEN blocks
pnpm tsx tools/asset-catalog/cli.ts verify <docs-dir>                     # check AUTOGEN blocks match what tool would emit (CI gate)

# Repo-level shortcut (in root package.json scripts)
pnpm catalog:all   # both client and server (server is Phase 3 sibling)
pnpm catalog:client
```

**Exit code matrix (mirror Phase 1 D-18 plan 01-06):**
- 0 = success
- 1 = functional failure (missing input dir, malformed meta.json, drift detected by `verify`)
- 2 = usage error (missing args, unknown subcommand)

### Cross-Ref Tokenization Discipline

The cross-ref pass is the heart of the tool. Get it wrong and every downstream subsystem MD is misleading. Key rules:

1. **Strip block comments first.** `code.replace(/\/\*[\s\S]*?\*\//g, '')` (GML 5.x has no nested block comments per `decomp/wiki/06-gml-syntax-5x.md`).
2. **Skip lines beginning with `//`.** Single-line comment.
3. **Word-boundary regex per identifier.** `new RegExp('\\b' + escapeRegex(name) + '\\b')`.
4. **DnD argument values: type-aware.** Per `argTypes`, Number()-convert Resource-* and Real before comparing to integer IDs.
5. **Direct field references in object meta.** `meta.spriteId` and `meta.maskId` are direct integer references; check both per object.
6. **Room transitivity.** A room consumes a sprite indirectly through its instances → object → spriteId chain. Test fixture must include this.
7. **Optional: `sprite_index` value-flow analysis.** Skip in Phase 2 — too expensive. Document as a Phase 6 lookup-on-demand if cross-ref is incomplete for a specific sprite.

### Performance on 8307 Files

Numbers from STATE.md plan 01-07 finalization: client tree = 83 MB / 8307 files. Per-resource counts: 854 sprites, 12 backgrounds, 198 scripts, 320 objects, 16 rooms, 3 datafiles. (No sounds, no fonts, no timelines — see §Audio Caveat.)

**Worst-case cross-ref pass:** for each of 854 sprites, run a regex against each of 198 scripts (avg ~50-200 lines). Total: 854 × 198 = ~170K regex applications. Modern Node can do this in seconds. No optimization needed; no incremental cache needed.

**Memory:** the entire `extracted/client-5-8/` tree's metadata + script source fits in <50 MB heap. Well under Node's default 1.5 GB limit.

**Recommendation:** don't pre-optimize. Write the straightforward implementation; if a future contributor adds 10× more assets it's still seconds. If profiling shows >30s, then add: (a) build a single mega-regex per pass via alternation, OR (b) tokenize scripts once and lookup against a token set.

### Determinism Strategy (mirror Phase 1 D-15)

| Concern | Strategy |
|---------|----------|
| Source enumeration order | `readdirSync(...).sort()` — alphabetic = numeric-prefix-sorted (Phase 1 emits `<NN>-<name>` so leading digits sort correctly). |
| JSON key order | `sortKeysDeep` recursive (see Pattern 3 example). |
| JSON formatting | `JSON.stringify(value, null, 2) + '\n'`. Replace any `\r\n` with `\n` post-stringify (Windows host safety). |
| Markdown ordering | Tables sorted by stable key (sprite ID for sprites, script ID for scripts, etc.). |
| Timestamps in output | NEVER. No `Date.now()`, no `new Date()` calls anywhere in `src/emit/`. Lint rule recommended: grep for `Date` in `src/emit/` rejects any match. |
| Absolute paths | NEVER. Strip `extracted-dir` prefix from any path stored in output. Source paths in `index.json` should be relative (`sprites/0042-Hexport/meta.json`), not absolute. |
| Floating-point | None expected (all numeric meta is int32 or bool); if a derived field happens to compute a float (e.g. ratio), round to a fixed precision. |
| Cross-platform PNG bytes | Not applicable in Phase 2 — no image encoding; D-07 keeps images opaque. |

## Subsystem-MD Authoring Workflow (per D-09 + D-11)

### File List & Source-Material Map

| Subsystem MD | Primary script sources | Primary object sources | Notes |
|--------------|------------------------|------------------------|-------|
| `rendering.md` | `0002-script_drawlines.gml`, `0263-mb_topdraw.gml`, `0264-mb_repdraw.gml`, `0267-mb_newdraw.gml`, `0269-draw_rqbar.gml`, `0298-iv_infodraw.gml`, `0303-iv_datadraw.gml`, `0311-iv_actiondraw.gml` | 0034-title (Draw event uses action 523), 0045-commandob (Draw uses action 525), 0118-alphaq, 0223-chatob, etc. | Heavy use of action 523/525 = font/text rendering primitives. Resolve via D-08 path (a) before drafting. |
| `input.md` | `0006-scroll.gml` (only 1 line: `for(f=0;f<30;f+=1) {global.line[f] = global.line[f+1]}` — appears to be chat scroll, not movement input) | 0042-player Keyboard-37/38/39/40 events (arrow keys), KeyPress-* for non-repeat, KeyRelease-* for stop | Find movement: object-event-driven, NOT script-driven. WASD = scancodes 65,68,83,87 visible in object 0042 event filenames. |
| `collision.md` | `0006-scroll.gml`? — actually misnamed; need to find. Search for `collision_*` in scripts. Found: `objects/0042-player/events/Step.gml` uses `collision_rectangle`. | All Collision-NN events on 0042-player (Collision-20, -28, -42, -54, -58, -346, -427, -573, -574); other_collision detected per object | Collision is event-driven (per-object Collision-N events), with `collision_rectangle` / `collision_line` calls in step. |
| `animation.md` | `objects/0042-player/events/Create.gml` (`image_speed = 0.8`), Alarm/Alarm-1..-5 events | 0042-player meta + spriteId chain | GameMaker animation primitives: `image_index`, `image_speed`, `sprite_index = X` swaps. Document the canonical idioms. |
| `scene-room-model.md` | Room transitions: search scripts for `room_goto`, `room_restart`. | All 16 rooms (Test, Main_Menu, Online_Lobby, Settings_Menu, Online_Command_Screen, etc.) | Each room has `instances.json`, `tiles.json`, `backgrounds.json` (per emit/tree.ts header). Room model is the GM5 area system — drives MATRIX row "scene/room model". |
| `save-load.md` | Search for `file_bin_*`, `file_text_*`, `ini_*`. Likely client-side reads only — server owns `.bnu`/`.bno`/`.bnb` writes per Phase 3. | Document client-side save/load only. | If client only reads server-pushed state, this MD says so explicitly. |
| `audio.md` | Search for `sound_play`, `playmidi`, `external_define.*sound`. | Document any external audio invocation. | **No `sounds/` directory in `extracted/client-5-8/`** (verified 2026-05-02). Either client has no embedded audio, or audio is loaded via 39dll/external. This MD's headline finding. |
| `ui-and-menus.md` | Multiple draw-related scripts (above). | Main_Menu, Online_Lobby, Settings_Menu, Online_Command_Screen rooms; their constituent objects (title, exit, connect, settings, version, cusername, tomainmenu, getip, alphaq, bkmbq, cpassword, serveraddress, help, notifq, tcprecq, tcpsendq, commandob, chatob — these match the action-523/525 unknowns) | Per D-01, depth equal to engine primitives. |
| `client-networking.md` | `0014-dllinit.gml` through `0026-setformat.gml` (TCP setup), `0023-sendmessage.gml`, `0024-receivemessage.gml`, `0025-peekmessage.gml`, `0044-getip` object | Document client-side 39dll usage; full opcode reverse is Phase 3. | Phase 3 owns the wire-protocol opcode table. Phase 2 documents "what client-side scripts call into the wire". |
| `admin-anti-port.md` | `legacy/open-source-release/,ServerCommands.txt`, `legacy/servers/enlyzeam-current/Ctrl+O Codes.txt` | None client-side (admin is server-side); referenced for completeness per D-02 | Each command/keybind catalogued with `REJECTED-AS-PORTED` marker + reason. Forcing function for Phase 7 PAR-07. |
| `unknown-actions-status.md` | Resolution status of `extracted/client-5-8/UNKNOWN-ACTIONS.md` entries | — | If path (a): one row per resolved action ID. If path (b): one row per deferred ID with reason. |
| `MATRIX.md` | — | — | Three columns (Phaser 3.90 / Phaser 4.1 / PixiJS 8.18); see §MATRIX Methodology. |
| `README.md` | — | — | Task-keyed jump table (per Specifics: "I want to know how the original game handled X → read Y.md"). |

### Hand-Authored + Autogen Hybrid Pattern (per D-11)

Each subsystem MD is structured:

```markdown
---
mvp: yes | no
subsystem: <name>
---

# <Subsystem name>

[hand-authored prose: how the engine handles this, key idioms,
 BNO-specific patterns, things the rebuild MUST replicate to stay faithful]

## Scripts referenced in this subsystem

<!-- AUTOGEN:scripts:start hash=<computed-by-tool> -->
<table generated from cross-ref index>
<!-- AUTOGEN:scripts:end -->

## Objects referenced in this subsystem

<!-- AUTOGEN:objects:start hash=... -->
<table generated from cross-ref index>
<!-- AUTOGEN:objects:end -->

## Engine functions used

<!-- AUTOGEN:gml-functions:start hash=... -->
<table: GML function name | call-site count | sample script | wiki link if available>
<!-- AUTOGEN:gml-functions:end -->

[hand-authored "rebuild guidance" section — what to preserve, what to modernize]
```

**Subsystem-membership criterion:** per D-09's locked list, each subsystem MD owns specific identifiers (script IDs, object IDs, GML function names). The membership rule is hand-curated in `docs/extracted-engine/SUBSYSTEM-MAP.json` (or YAML), consumed by autogen-block generators. Example:

```json
{
  "rendering": {
    "scripts": [2, 263, 264, 267, 269, 298, 303, 311],
    "objects": [34, 45, 118, 223, 325, 359, 384, 394, 423, 464, 480],
    "gml-functions": ["draw_set_font", "draw_set_color", "draw_set_halign", "draw_set_valign", "draw_text", "draw_sprite", "draw_sprite_ext"]
  },
  "input": { "scripts": [], "objects": [42], "gml-functions": ["keyboard_check", "keyboard_check_pressed", "keyboard_check_released"] }
}
```

This file is the bridge between the catalog (mechanical) and the subsystem narrative (judgmental). Editing it is a deliberate act; the autogen pass mechanically refreshes the tables.

### `mvp:` Tag Format Recommendation

**Recommend YAML front-matter** (see Code Examples). Pros / cons matrix:

| Format | Renders cleanly in markdown viewers | Grep-friendly | Type-friendly | Co-located with prose |
|--------|--------------------------------------|---------------|---------------|------------------------|
| YAML front-matter (recommended) | ✓ (hidden) | ✓ | ✓ | ✓ |
| Fenced metadata block | ✗ (visible) | ✓ | ✓ | ✓ |
| Sidecar TOML/JSON file | ✓ | ✓ | ✓ | ✗ (drifts) |
| In-prose tag (e.g. "**MVP:** yes") | ✓ | ✗ (false-positive matches) | ✗ | ✓ |

YAML is the lightweight winner; the `mvp:` extractor is a 5-line awk/regex helper.

## MATRIX Methodology (per D-12)

### Row Generation

Every documented CDOC-01 feature becomes one MATRIX row. Rows MUST cite the subsystem MD that documents the feature. Recommended row schema (TS type):

```typescript
interface MatrixRow {
  rowId: string;          // stable, e.g. "MX-RENDER-01"
  subsystem: string;      // matches a CDOC-01 subsystem MD basename
  feature: string;        // human-readable feature name
  bnoUsage: string;       // "heavy" | "light" | "none" — drives weight (see below)
  weight: number;         // 1-5; summed across all rows = total weight budget
  cite: string;           // path:section in subsystem MD (e.g. "rendering.md#draw-text-with-custom-fonts")
  scores: {
    'phaser-3.90': { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
    'phaser-4.1':  { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
    'pixi-8.18':   { grade: 'native' | 'plugin' | 'manual' | 'hard'; note: string };
  };
}
```

**Grade-to-score mapping (per PITFALLS D3):**
- `native` = 4 (engine ships first-class support)
- `plugin` = 3 (well-maintained community plugin available)
- `manual` = 2 (engine provides primitives; you write the integration)
- `hard` = 1 (workaround would fight the engine — D-13 hard-knockout candidate if weight ≥ 4)

**Weighted score per engine** = Σ(row.weight × score(row, engine)) for all rows.

### Weight Discipline (per Specifics + Pitfall 5)

Weights live in `docs/extracted-engine/MATRIX-rows.json` (the same JSON quoted above). Subsystem MD line "Weights captured as data" requirement is satisfied by:

1. The JSON file is the source of truth for both weights and scores.
2. MATRIX.md is rendered from this JSON (a tiny generator in `tools/asset-catalog/src/emit/matrix.ts` — or part of the autogen-block scheme; the entire MATRIX.md body is one giant AUTOGEN block).
3. ADR cites `rowId` values that exist in the JSON. CI verification: every cited rowId resolves.

### Suggested Row List (for planner reference; not exhaustive)

These rows are derived from CDOC-01 subsystems + sample CDOC-01 scripts inspected. The planner may add rows as subsystem MDs are drafted. Every row cites a candidate Phaser/Pixi API based on docs reads:

| Row ID | Subsystem | Feature | Weight (sketch) |
|--------|-----------|---------|------------------|
| MX-RENDER-01 | rendering | Sprite rendering with origin point + bounding box + collision mask (GM `sprite_index`/`mask_index`/`originX`/`originY`/`bbox*`) | 5 (heavy: every visible object) |
| MX-RENDER-02 | rendering | Tile-background drawing with offset/separation (GM background-as-tileset) | 4 (rooms use tiles) |
| MX-RENDER-03 | rendering | Per-instance depth ordering (GM `depth` int32; lower = drawn on top) | 4 |
| MX-RENDER-04 | rendering | Custom-font text rendering (GM `draw_set_font` + `draw_text`; action 523) | 4 (UI-heavy) |
| MX-RENDER-05 | rendering | Color tinting + blend modes (GM `draw_sprite_ext`'s blend / alpha args) | 3 |
| MX-RENDER-06 | rendering | Surface / render-texture (GM `surface_create`/`surface_set_target`) | 2 (verify usage; may be 1) |
| MX-INPUT-01 | input | Per-key keyboard event handling (GM Keyboard-N events) | 5 (movement) |
| MX-INPUT-02 | input | Key-press vs key-held vs key-release distinction (GM KeyPress-N / Keyboard-N / KeyRelease-N) | 4 |
| MX-INPUT-03 | input | Mouse input (verify usage before weighting) | 2 |
| MX-COLLIDE-01 | collision | Per-object Collision events with target object filter | 5 (movement vs walls) |
| MX-COLLIDE-02 | collision | `collision_rectangle` / `collision_line` / `place_meeting` runtime queries | 4 |
| MX-COLLIDE-03 | collision | Precise per-pixel collision masks (sprite `precise: true`) | 3 (verify how many sprites use it) |
| MX-ANIM-01 | animation | Animated sprite frames with `image_speed` per-frame interpolation | 4 |
| MX-ANIM-02 | animation | Sprite-swap animation (`sprite_index = X` mid-flight) | 4 |
| MX-SCENE-01 | scene-room-model | Room model with size, view, view-port, view-following | 5 (16 rooms) |
| MX-SCENE-02 | scene-room-model | Room transitions (`room_goto` etc.) | 4 |
| MX-SCENE-03 | scene-room-model | Per-room creation code + per-instance creation code | 3 |
| MX-AUDIO-01 | audio | WAV / MIDI / MP3 playback | 1 if no client-side audio assets confirmed; 3 if scripts call sound_play |
| MX-SAVE-01 | save-load | Client-side state save/load (verify scope) | 1-2 (Phase 3 owns server `.bnu`/`.bno`/`.bnb`) |
| MX-UI-01 | ui-and-menus | DOM-overlay UI vs canvas UI compatibility (chat HUD, menus) | 4 |
| MX-NET-01 | client-networking | WebSocket client integration (Phase 6 will use Colyseus client; documented for completeness) | 2 (engine-agnostic; not a real differentiator) |

**Total weight budget (sum of weights):** target around 60-80 so individual scores have meaningful spread. Weights are HYPOTHETICAL above; planner finalizes after subsystem MDs are drafted.

## Hard-Knockout Watch List (per D-13)

These are the CDOC-01 features most likely to surface as Phaser 3 hard-knockouts (forcing PixiJS per D-13):

| Concern | Why Phaser 3 might struggle | Mitigation cost (if "hard" graded) |
|---------|------------------------------|-----------------------------------|
| **GM-style integer-pixel sprite rendering at HiDPI** | Phaser 3's renderer can do nearest-neighbor + integer scale (CLI-06 requirement). Verify config flag + camera setup; some engines fight pixel-perfect at non-integer DPR. | Low — Phaser 3.90 supports `pixelArt: true` config + camera roundPixels. Likely `native`. |
| **Custom blend modes per sprite** | Phaser 3's blend mode set is ~12 named modes (NORMAL, ADD, SCREEN, MULTIPLY, etc.). If BNO uses an unusual blend (verify in scripts via `draw_sprite_ext` blend arg), the only fix is custom shader. | Medium — depends on which blends BNO uses. Document in `rendering.md`. |
| **Pixel-precise collision masks (per-sprite `precise: true`)** | Phaser 3's bodies are AABB (Arcade) or polygon (Matter); pixel-precise mask collision is plugin territory. | Medium-high — would need `phaser-pixel-perfect-collision` plugin or manual collision. Counts as `plugin` not `native`; weight-dependent on actual usage. |
| **Tile background with horizontal/vertical separation** (GM `background_*` props) | Phaser tilemap supports tile margins/spacing but the GM tile-coordinate model differs slightly. | Low-medium — likely `manual` (extract tile sheet + define Phaser tilemap config). |
| **Per-instance depth ordering with arbitrary integer depth** | Phaser 3's depth is per-Game-Object via `setDepth(n)` — `native` mapping. | Low — direct API match. |
| **Custom-font rendering with arbitrary OS fonts (action 523, 525)** | Phaser 3 supports BitmapText (sprite-strip) + native browser fonts via `Phaser.GameObjects.Text`. The original `OCRA` and `Fixedsys` fonts will likely become BitmapText assets via AST-03 (Phase 7). | Low — `native` for BitmapText. AST-03 burden, not Phaser. |
| **Surface / render-to-texture mid-frame** (`surface_create`) | Phaser 3 has `RenderTexture` — `native` if BNO uses surfaces sparingly. If heavy and per-frame, may stress draw calls. | Low — verify usage frequency; likely `native`. |
| **Audio: MIDI playback** | NO browser supports MIDI natively; AST-02 converts MIDI→OGG (Phase 7). Phaser 3 audio handles OGG `native`. | Low for Phase 2; weight is on AST-02 (Phase 7), not engine choice. |
| **Audio: client-side audio** | If verified there are no client-side audio assets, this row's weight is 1, and the audio score barely matters. | Low — likely a non-factor. |

**Predicted outcome:** No hard-knockout. Phaser 3 likely scores highest. ADR likely locks Phaser 3 (with Phaser 4 as a secondary option for Stage 6 re-evaluation). But the matrix MUST be filled honestly per the actual subsystem MDs — DO NOT pre-commit the ADR before MATRIX is filled.

## Phaser 4 Caveat

STACK.md (researched 2026-05-01) recommends Phaser 3.90 with explicit decision-gate language:
> "Phaser 3 → Phaser 4 (4.1.0, April 2026) | If starting after ~Q3 2026 once the v4 ecosystem (plugins, tutorials, AI scaffolding) catches up. v4 is faster and architecturally cleaner but still settling. **Decision gate: re-evaluate at Stage 6 start.**"

Verified on 2026-05-02: `npm view phaser dist-tags` returns `latest: 4.1.0`. Phaser 4 is GA, not beta. CDOC-04 is the explicit engine-locking ADR — it MUST address Phaser 4 explicitly so Phase 6 doesn't silently re-open.

**Required ADR coverage:**
1. Add a third MATRIX column for Phaser 4.1.0.
2. Score each row for Phaser 4 (most rows will inherit Phaser 3 scores; Phaser 4 added APIs not lost APIs).
3. ADR Decision section explicitly addresses: "Why Phaser 3.90 rather than Phaser 4.1?" or vice versa.
4. ADR Consequences section notes the migration cost if Phase 6 wants to flip later.
5. Cite the [Phaser 3 → 4 Migration Guide](https://phaser.io/news/2026/04/migrating-from-phaser-3-to-phaser-4-what-you-need-to-know) (per STACK.md sources): "biggest release ever, migration mostly automatic but still recent" is the pivotal nuance.

**Pre-recorded position (subject to override by MATRIX):** Phaser 3.90 is the safer Phase 6 choice — STACK.md's "still settling" language for v4 + the "ship MVP fast" pressure of CLI-08 favor v3. v4 becomes attractive at Phase 7 (full parity) when stability matters less and performance/architecture matters more. Document this thinking in the ADR.

## ADR Format + Citation Discipline

Per D-14: Status / Context / Decision / Consequences. Specifically:

```markdown
# ADR 0001: Client engine choice

**Status:** Accepted
**Date:** 2026-MM-DD
**Phase:** 02 close

## Context

[2-4 paragraphs summarizing CDOC-01 + CDOC-02 findings: what the BNO client engine actually does, what subsystems matter, which assets dominate. Cites: docs/extracted-engine/README.md, docs/extracted-engine/MATRIX.md.]

## Decision

We will use **Phaser 3.90.0** for the Phase 6 MVP and Phase 7 parity client.

(Or: We will use PixiJS 8.18.1 + @pixi/tilemap@5.0.2 + howler@2.2.4. Or: Phaser 4.1.0.)

Rationale, by MATRIX row:
- Phaser 3.90 scores **{N}** weighted; PixiJS 8.18 scores **{M}**; Phaser 4.1 scores **{P}**.
- Decisive rows: MX-SCENE-01 (Phaser native room/scene model, see MATRIX.md row), MX-COLLIDE-01 (Phaser native event-based collision; PixiJS would require manual integration), MX-AUDIO-01 (low weight per CDOC-01 audio findings, see MATRIX.md row).
- Phaser 4.1 considered (npm `latest` as of 2026-05-02). Rejected for MVP because [scoring detail + STACK.md "still settling" caveat]. Re-evaluation gate: Phase 6 start.

## Consequences

- Phase 6 (CLI-01, CLI-04, CLI-06) commits to Phaser 3.90 patterns.
- AST-01 (Phase 6) targets Phaser atlas JSON format (`pnpm add phaser` in apps/client).
- AST-02 (Phase 7 MIDI→OGG) targets Phaser audio loader.
- AST-03 (Phase 7 fonts) targets Phaser BitmapText format.
- If Phaser 4 ecosystem matures by Phase 7 retro, migration cost is one apps/client refactor (per migration guide, "mostly automatic"). Re-evaluation timing: post-CLI-08, pre-Phase-7-PR-01.

## References
- docs/extracted-engine/MATRIX.md (rows MX-SCENE-01, MX-COLLIDE-01, MX-AUDIO-01, MX-RENDER-04)
- .planning/research/STACK.md §"Phaser 3 → Phaser 4"
- [Phaser 3 → 4 Migration Guide](https://phaser.io/news/2026/04/migrating-from-phaser-3-to-phaser-4-what-you-need-to-know)
```

## D-08 Resolution Strategy

`extracted/client-5-8/UNKNOWN-ACTIONS.md` (verified 2026-05-02) contains exactly **two unique action IDs across 27 sites**:

| Action ID | Sites | Likely identity (from .dnd.json inspection) |
|-----------|-------|---------------------------------------------|
| 523 | 16 sites (objects 0034-0480, all Draw events) | LateralGM "Set font" — args (font_name string, size real, color real, halign real, valign real, antialias real). Verified by inspection of 0034-title Draw.dnd.json. |
| 525 | 11 sites (objects 0045-0223, Draw events) | LateralGM "Set font (combined)" — single composite string arg "name,size,color,bold,italic,halign,valign". Verified by inspection of 0045-commandob Draw.dnd.json. |

**Recommendation: D-08 path (a).** Cost is bounded: two action entries to add to `tools/extract-gmd/data/action-ids.json`, then one `pnpm extract:all` re-run on `BN Online Client 5-8.gmd`. Net result: 27 `Draw.gml` files emit clean transcompiled code instead of `// UNKNOWN ACTION_ID=523; see .dnd.json` markers. Subsystem MDs (especially `rendering.md` and `ui-and-menus.md`) become readable.

**Implementation note:** the porter (`tools/extract-gmd/scripts/port-action-ids.ts` per Phase 1 plan 01-03) reads LateralGM `.lgl` binary action library files. The two missing entries should be present in LateralGM 1.8.234's stock libraries (the porter currently surfaces 134 unique IDs; the canonical LateralGM library has more — these two are part of an unparsed library file or were filtered. Confirm by re-running the porter against LateralGM source and checking for IDs 523/525). If they're missing from LateralGM stock, hand-author the entries based on the .dnd.json signature and document in `tools/extract-gmd/data/README.md`.

**`unknown-actions-status.md` content (path a outcome):**
```markdown
# Unknown DnD Action ID Resolution Status

| Action ID | Status | Resolution |
|-----------|--------|------------|
| 523 | RESOLVED | Ported from LateralGM library entry "Set font" (2026-MM-DD); see action-ids.json |
| 525 | RESOLVED | Ported from LateralGM library entry "Set font (combined)" (2026-MM-DD); see action-ids.json |

After resolution: extracted/client-5-8/UNKNOWN-ACTIONS.md no longer emitted (zero unknown IDs in source).
```

## Audio Caveat (extraction artifact)

Verified 2026-05-02: `extracted/client-5-8/` has NO `sounds/` directory, NO `fonts/` directory, NO `timelines/` directory. Only:
- `backgrounds/` (12)
- `datafiles/` (3)
- `objects/` (320)
- `paths/` (verify count)
- `rooms/` (16)
- `scripts/` (198)
- `sprites/` (854)

This means `audio.md` cannot be a sounds-asset-walkthrough. It must answer: "how does the BNO client play audio?" The extraction emit (per `tools/extract-gmd/src/emit/tree.ts` header) WOULD emit `sounds/<NN>-<name>/audio.<ext>` if any sound resources existed in the .gmd. They don't. Two possibilities:
1. Client `.gmd` carries no embedded audio (audio is server-side or runtime-loaded via 39dll).
2. Phase 1's reader missed sounds (unlikely — STATE.md plan 01-07 verifies extraction parity against LateralGM).

**Recommendation:** in `audio.md`, document the absence as a finding, then grep client scripts for `sound_play|playmidi|external_define|sound_load` to identify any runtime audio invocation. If none, MX-AUDIO-01 weight drops to 1 in MATRIX (effectively eliminating audio as an engine differentiator).

Same caveat for `fonts/` (no font resources → `rendering.md` font discussion is about runtime-named OS fonts via action 523, not embedded raster fonts) and `timelines/` (no timeline resources → no timeline subsystem narrative needed; `animation.md` uses GameMaker's image_index/Alarm-N pattern only).

## State of the Art (project-relevant deltas)

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Phaser 3 as `latest` on npm | Phaser 4.1 as `latest` | 2026-04 | MATRIX must include Phaser 4 column. ADR must explicitly justify v3 vs v4. |
| Phaser 3.55 docs (linked from older tutorials) | Phaser 3.90 docs at docs.phaser.io | 2026-Q1 | Cite current docs in MATRIX cells. |
| PixiJS 7.x | PixiJS 8.18 | 2026-04-14 | `@pixi/tilemap@5.0.2` requires Pixi 8.5+; lock the dependency chain in MATRIX cells. |
| LateralGM 1.8.234 (Phase 1 baseline) | Same | — | No newer release has Phase-2-relevant deltas. action-ids.json port targets 1.8.234. |
| Phaser CE / Phaser 2 | EOL | — | DO NOT score against EOL versions. |

## Existing Patterns to Mirror (from `tools/extract-gmd`)

Verified by Read on 2026-05-02:

### Project layout
```
tools/extract-gmd/
├── README.md
├── cli.ts                 ← #!/usr/bin/env node, exports main(argv: string[]): Promise<number>
├── data/
│   ├── README.md
│   └── action-ids.json    ← LateralGM port; D-08 path (a) extends THIS file
├── package.json           ← name=extract-gmd, type=module, scripts.test=vitest run --exclude integration
├── package-lock.json
├── tsconfig.json
├── vitest.config.ts
├── scripts/               ← one-off porter scripts (port-action-ids.ts)
├── src/
│   ├── extract.ts         ← public API: extract(inputPath, outDir): Promise<void>
│   ├── verify.ts          ← public API: verify(srcDir): VerifyResult
│   ├── types.ts           ← canonical resource types (B3 reconciliation)
│   ├── reader/            ← block readers (header, settings, sounds, sprites, ...)
│   ├── dnd/               ← DnD-specific (readAction, transcompile, actionLookup)
│   └── emit/              ← deterministic file emitters (json, png, manifest, tree, unknown-actions)
└── tests/
    ├── reader/, dnd/, emit/, integration/
    └── fixtures/          ← build-fixtures.ts + BufferWriter primitives
```

### Conventions to inherit
1. **CLI entry (`cli.ts`)**: shebang `#!/usr/bin/env node`, exit code matrix 0/1/2 (success/functional-failure/usage-error), `printUsage()` on missing args.
2. **Public API in `src/`**: barrel via `src/extract.ts` style — single async function for main pipeline.
3. **Strict TS**: `tsconfig.json` extends a strict base (`noUncheckedIndexedAccess` etc.); pre-existing convention.
4. **Test layout**: unit tests parallel `src/` directory structure; `tests/integration/` for end-to-end with real `.gmd` (in extract-gmd's case) or real `extracted/<src>/` tree (for asset-catalog).
5. **Determinism tests**: explicit "re-run produces byte-identical output" test (Phase 1 plan 01-05's TR-10).
6. **Vitest config**: `maxWorkers: 1` (matches Phase 1 plan 01-01 note about removed `singleFork`).
7. **No production dependencies**: extract-gmd has only `sharp` (for PNG, deferred to Phase 6/7). Asset-catalog needs nothing — pure stdlib.
8. **README as thin wrapper**: per D-19 / Phase 1 plan 01-06; project-level docs link to `decomp/wiki/`, tool README links to broader plan docs.

### Repo-level glue
1. Add to root `package.json` scripts:
   - `"catalog:client": "tsx tools/asset-catalog/cli.ts extracted/client-5-8 docs/extracted-engine"`
   - `"catalog:all": "pnpm catalog:client"` (Phase 3 will add `catalog:server` extending this)
2. CI gate: `pnpm catalog:client && git diff --exit-code docs/extracted-engine/asset-catalog/` (catalog must be checked in current).

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node 22 LTS | tools/asset-catalog runtime | ✓ (Phase 1 verified) | 22.x | — |
| TypeScript 5.6.3 | typecheck | ✓ (extract-gmd/package.json) | 5.6.3 | — |
| pnpm | install + scripts | ✓ (Phase 1 verified) | 10.x | — |
| `extracted/client-5-8/` | input | ✓ (Phase 1 plan 01-07 committed) | 8307 files, 83 MB | — (blocking; see below) |
| LateralGM 1.8.234 source | D-08 path (a) action-ID port | requires re-clone (Phase 1 used it) | 1.8.234 tag | Path (b) — defer with reason |
| Phaser 3.90 docs (web) | MATRIX scoring | ✓ at docs.phaser.io | 3.90 | Cached page archive if site unavailable |
| Phaser 4.1 docs (web) | MATRIX scoring | ✓ at docs.phaser.io | 4.1 | Migration guide page archive |
| PixiJS 8.18 docs (web) | MATRIX scoring | ✓ at pixijs.io | 8.18 | Cached page archive |

**Missing dependencies with no fallback:** none.

**Missing dependencies with fallback:** LateralGM source — if path (b) is chosen for D-08, the dependency drops out.

**Blocking precondition check:** before any plan starts, confirm `extracted/client-5-8/MANIFEST.sha256` exists and is committed. (It is per STATE.md plan 01-07.)

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 4.1.5 (matches Phase 1 plan 01-01 pin) |
| Config file | `tools/asset-catalog/vitest.config.ts` (Wave 0 creates) |
| Quick run command | `pnpm --filter asset-catalog test` (or `cd tools/asset-catalog && pnpm test`) |
| Full suite command | `pnpm --filter asset-catalog test:full` (includes integration test against real `extracted/client-5-8/`) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CDOC-01 | Every CDOC-01 subsystem MD exists at `docs/extracted-engine/<name>.md` | unit (filesystem assert) | `pnpm --filter asset-catalog test tests/structure.test.ts -t "all subsystem MDs present"` | ❌ Wave 0 |
| CDOC-01 | Every subsystem MD's mvp tag is `yes` or `no` (no missing values) | unit | `pnpm --filter asset-catalog test tests/structure.test.ts -t "mvp tag valid"` | ❌ Wave 0 |
| CDOC-01 | Every script ID in extracted/client-5-8/scripts/ is reachable from at least one subsystem MD via SUBSYSTEM-MAP.json | integration | `pnpm --filter asset-catalog test tests/integration/subsystem-coverage.test.ts` | ❌ Wave 0 |
| CDOC-01 | Every object ID in extracted/client-5-8/objects/ is reachable from at least one subsystem MD | integration | (same as above) | ❌ Wave 0 |
| CDOC-01 | Zero `UNKNOWN ACTION_ID` markers in extracted Draw.gml files at sign-off (D-08 path (a) outcome) | integration | `pnpm --filter asset-catalog test tests/integration/no-unknown-actions.test.ts` OR `! grep -r "UNKNOWN ACTION_ID" extracted/client-5-8/` | ❌ Wave 0 |
| CDOC-02 | `docs/extracted-engine/asset-catalog/index.json` exists and parses as valid JSON | unit | `pnpm --filter asset-catalog test tests/emit-determinism.test.ts -t "index.json valid"` | ❌ Wave 0 |
| CDOC-02 | Re-running `tools/asset-catalog` against the same input produces byte-identical `index.json` and `index.md` | integration (D-16 determinism) | `pnpm --filter asset-catalog test tests/integration/determinism.test.ts` | ❌ Wave 0 |
| CDOC-02 | Every sprite in `extracted/client-5-8/sprites/` has an entry in `index.json` with `used_by` populated | integration | `pnpm --filter asset-catalog test tests/integration/coverage.test.ts -t "every sprite cataloged"` | ❌ Wave 0 |
| CDOC-02 | Every script that mentions a sprite name (word-boundary, comment-stripped) appears in that sprite's `used_by.scripts` (cross-ref correctness) | unit + fixture | `pnpm --filter asset-catalog test tests/crossref.test.ts -t "sprite-script cross-ref"` | ❌ Wave 0 |
| CDOC-02 | Identifier-prefix collision regression test: sprites named `Foo`, `FooBar`, `FooBaz` cross-ref independently when only one is mentioned | unit | `pnpm --filter asset-catalog test tests/crossref.test.ts -t "no prefix-substring collision"` | ❌ Wave 0 |
| CDOC-03 | `docs/extracted-engine/MATRIX.md` exists with a Phaser 3.90 column, Phaser 4.1 column, PixiJS 8.18 column, and at least one row per CDOC-01 subsystem | structure | `pnpm --filter asset-catalog test tests/structure.test.ts -t "MATRIX has all engine columns"` | ❌ Wave 0 |
| CDOC-03 | `MATRIX-rows.json` exists with weights as data; rendered MATRIX.md totals match the data (Pitfall 5 mitigation) | structure | `pnpm --filter asset-catalog test tests/structure.test.ts -t "MATRIX totals match data"` | ❌ Wave 0 |
| CDOC-04 | `docs/adr/0001-client-engine.md` exists with all four sections (Status, Context, Decision, Consequences) | structure | `pnpm --filter asset-catalog test tests/structure.test.ts -t "ADR has required sections"` | ❌ Wave 0 |
| CDOC-04 | Every MATRIX rowId cited in the ADR exists in MATRIX-rows.json (Pitfall 6 mitigation) | structure | `pnpm --filter asset-catalog test tests/structure.test.ts -t "ADR row citations resolve"` | ❌ Wave 0 |
| (D-04) | A future tool can `grep`-extract `mvp: yes` features in <10 ms across all subsystem MDs | manual | `time awk '/^---$/{f++;next} f==1' docs/extracted-engine/*.md \| grep -c '^mvp: yes'` | manual |
| (D-08) | UNKNOWN-ACTIONS.md is empty OR `unknown-actions-status.md` exists and resolves every entry | structure | combination of the two preceding integration tests; failing either breaks sign-off | ❌ Wave 0 |
| (D-16) | All catalog output uses LF line endings (Windows-host safety) | unit | `pnpm --filter asset-catalog test tests/emit-determinism.test.ts -t "LF only"` | ❌ Wave 0 |
| (D-16) | All catalog output omits absolute paths and timestamps | unit | `pnpm --filter asset-catalog test tests/emit-determinism.test.ts -t "no absolute paths or dates"` | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `pnpm --filter asset-catalog test` (unit tests only — fast, < 5 s)
- **Per wave merge:** `pnpm --filter asset-catalog test:full` (includes integration tests against real `extracted/client-5-8/`)
- **Phase gate:** Full suite green + manual ADR sanity-read + `git diff --exit-code docs/extracted-engine/asset-catalog/` (catalog committed current).

### Wave 0 Gaps
- [ ] `tools/asset-catalog/` directory + `package.json` + `tsconfig.json` + `vitest.config.ts` + `cli.ts` skeleton
- [ ] `tools/asset-catalog/tests/fixtures/tiny/` — synthetic minimal `extracted/` tree for unit tests (3 sprites, 2 scripts, 2 objects, 1 room — enough to exercise cross-ref and determinism)
- [ ] `tools/asset-catalog/tests/structure.test.ts` — covers all CDOC-01..04 structural assertions
- [ ] `tools/asset-catalog/tests/crossref.test.ts` — covers Pitfalls 1-4 with explicit collision fixtures
- [ ] `tools/asset-catalog/tests/emit-determinism.test.ts` — covers D-16 + LF + sort
- [ ] `tools/asset-catalog/tests/integration/determinism.test.ts` — re-run against real `extracted/client-5-8/`, byte-compare
- [ ] `tools/asset-catalog/tests/integration/coverage.test.ts` — every sprite/script/object reachable
- [ ] `tools/asset-catalog/tests/integration/subsystem-coverage.test.ts` — D-09 SUBSYSTEM-MAP.json exhaustiveness
- [ ] `tools/asset-catalog/tests/integration/no-unknown-actions.test.ts` — D-08 sign-off gate
- [ ] `docs/extracted-engine/SUBSYSTEM-MAP.json` — initial scaffold mapping subsystem → owned identifiers (planner+author hand-curate)
- [ ] `docs/extracted-engine/MATRIX-rows.json` — initial row scaffold from §MATRIX Methodology suggested rows
- [ ] Root `package.json` `scripts.catalog:client` and `scripts.catalog:all`
- [ ] Framework install: not needed — vitest already present in `tools/extract-gmd/package.json`; replicate the pin

## Security Domain

> Phase 2 is documentation + offline tooling. The security surface is narrow but not zero.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | yes (low) | Catalog input is trusted Phase 1 output, but path sanitization (already in `tools/extract-gmd/src/emit/tree.ts`) must be inherited if asset-catalog ever writes files into asset-catalog/ named after sprites. Recommend: emit only into known-safe `index.json` / `index.md` plus AUTOGEN replacements in known files; never write a file named after a resource. |
| V6 Cryptography | no | — |
| V7 Error Handling | yes (low) | CLI exit codes; no stack-trace leaks to logs (matches Phase 1 plan 01-06 behavior). |
| V8 Data Protection | yes (medium) | Phase 1 plan 01-07 forensic finding: `Jarhead111` and other hardcoded plaintext usernames live in extracted scripts. The catalog MUST NOT amplify them — do NOT include script source content in `index.json`. Catalog stores script IDs/names + which subsystem they belong to; full source stays in `extracted/`. |
| V9 Communication | no | — |

### Known Threat Patterns for this stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Path traversal in resource name (e.g. sprite named `../../../etc/passwd`) | Tampering | Path sanitization (already done by Phase 1 emit; Phase 2 reads sanitized paths). DO NOT re-derive paths from `meta.name` — use the directory name (already sanitized). |
| Catalog amplifies hardcoded plaintext credentials by indexing script content | Information Disclosure | Catalog stores script ID + name + subsystem mapping ONLY; never script source. Plaintext credentials remain in `extracted/` (gated by repo-private-through-Phase-7 rule per CLAUDE.md hard rule #8). |
| Untrusted .md content in autogen-block target gets clobbered | Tampering | AUTOGEN markers are HTML comments with optional `hash=` attribute; verifier subcommand catches any drift between rendered and expected content. |
| Capcom-derived asset names (sprite "MegaMan", etc.) appear in index.md and become a public-search hit if repo accidentally pushed | Information Disclosure (legal) | Repo stays private through Phase 7 per CLAUDE.md hard rule #8. Phase 2 MUST NOT push docs to a public mirror. CONCERNS.md "Unlicensed derivative work" reasoning applies. |

## Project Constraints (from CLAUDE.md)

| Directive | Source | Phase 2 Implication |
|-----------|--------|---------------------|
| Server-authoritative discipline | CLAUDE.md hard rule #1 | Not applicable (no runtime in Phase 2). |
| No faithful port of plaintext passwords | CLAUDE.md hard rule #2 | Catalog must not store/index plaintext credentials. See §Security Domain V8. |
| No faithful port of clipboard-RCE admin | CLAUDE.md hard rule #3 | `admin-anti-port.md` (D-02) is the FORCING FUNCTION — catalog the original surface so Phase 7 can't miss anything. |
| Extract → document → rewrite | CLAUDE.md hard rule #6 | Phase 2 IS the "document" step. NO new TypeScript outside `tools/`. The plan-checker MUST reject `apps/` or `packages/` additions. |
| Modern decompilers cannot read GM 5.3a | CLAUDE.md hard rule #7 | Not applicable (extraction already complete in Phase 1). |
| Repo stays private through Phase 7 | CLAUDE.md hard rule #8 | Subsystem MDs and asset catalog use Capcom-derived names. NEVER push to a public mirror. |
| TypeScript everywhere, strict mode, shared types via packages/protocol | CLAUDE.md Conventions | `tools/asset-catalog` is TS strict, mirrors `tools/extract-gmd` tsconfig. |
| ADRs at `docs/adr/NNNN-title.md` | CLAUDE.md Conventions | D-14 honors this exactly: `docs/adr/0001-client-engine.md`. |
| Each plan = one commit | CLAUDE.md Conventions | Planner: structure plans as one-commit-per-plan; reference REQ-IDs (CDOC-01..04) in commit messages. |
| Phase 1, 3, 7 are research-flagged; Phase 2 is standard | ROADMAP.md / CLAUDE.md | Phase 2 doesn't need DEEPER research beyond this RESEARCH.md. The phase-research flag is OFF for Phase 2 (per ROADMAP "Standard pattern"). |

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Action IDs 523 and 525 are LateralGM "Set font" / "Set font (combined)" entries available in LateralGM 1.8.234 stock libraries | §D-08 Resolution Strategy | If they're not in the stock libraries, path (a) requires hand-authoring the entries (still feasible — argument shape is verified by .dnd.json inspection) or falls back to path (b). Cost delta: a few hours. |
| A2 | Cross-ref pass at scale (854 sprites × 198 scripts ≈ 170K regex applications) completes in seconds on a developer laptop | §Performance on 8307 Files | If slower than expected, optimization is straightforward (mega-regex via alternation; documented as deferred). No design change. |
| A3 | `audio.md` is largely "no client-side audio assets" because `extracted/client-5-8/` has no `sounds/` directory | §Audio Caveat | If client scripts call `sound_play` against runtime-loaded audio (verifiable in Wave 0 via grep), the MD describes the runtime-loading pattern instead. Doesn't break planning. |
| A4 | YAML front-matter is the right `mvp:` format vs alternatives | §`mvp:` Tag Format Recommendation | If GitHub markdown rendering quirks affect the team, alternatives (sidecar TOML, fenced block) are easy switches. Low impact. |
| A5 | Phaser 3.90 will likely win the MATRIX (no hard-knockout) | §Hard-Knockout Watch List | If MATRIX surfaces a Phaser hard-knockout (e.g. precise-pixel collision is critical and Phaser 3 plugin is poorly maintained), ADR flips to PixiJS — D-13 explicitly accommodates this. The research is honest that this is an *expected* outcome, not a *predicted* one. |
| A6 | `extracted/client-5-8/` has no fonts/timelines tree because the source `.gmd` carries none | §Audio Caveat (extends to fonts/timelines) | If Phase 1 missed them, Phase 2 finds bare slots. Highly unlikely per STATE.md plan 01-07 LateralGM-parity claim. |
| A7 | LateralGM 1.8.234 source is still accessible and the porter (`tools/extract-gmd/scripts/port-action-ids.ts`) re-runs cleanly | §Existing Patterns to Mirror | Already validated in Phase 1 plan 01-03; if the porter has bit-rotted, it must be repaired before D-08 path (a). |
| A8 | The cross-ref index can use opaque `meta.spriteId` integer references in object/room metadata without needing to walk DnD action arguments for every reference | §Pitfall 3 | Many sprite references are NOT in object meta — they're in DnD action arguments (e.g. `Set sprite` action with sprite ID). Cross-ref MUST also walk DnD args; Pitfall 3 covers this. If the planner misses Pitfall 3, cross-ref completeness suffers. |

## Open Questions (RESOLVED)

All open questions resolved 2026-05-02 prior to plan finalization. Dispositions below.

1. **Are LateralGM action IDs 523 and 525 in stock LateralGM 1.8.234 libraries, or do they live in a non-default action library file?**
   - **Disposition: RESOLVED.** Hand-authored entries are committed in Plan 02-02 task 1 against the verified `.dnd.json` argument signatures. Whether or not the porter would have surfaced them from stock libraries is irrelevant once the entries are committed (provenance documented in `tools/extract-gmd/data/README.md` per Plan 02-02 task 1 step 3).
   - What we know: BNO objects use both IDs; `.dnd.json` argument shape is unambiguous.
   - Recommendation (executed): Plan 02-02 task 1 hand-authors entries; porter re-run is optional follow-up only.

2. **Does the BNO client play any audio at runtime, or is audio entirely server-pushed (or absent)?**
   - **Disposition: RESOLVED.** Deferred to Plan 02-05 task 2 `audio.md` authoring; the absence of `extracted/client-5-8/sounds/` is already confirmed (Phase 1 plan 01-07). Pitfall 8 mitigation (grep for `sound_play` / `playmidi` / `external_define.*sound` in `extracted/client-5-8/scripts/`) is owned by Plan 02-05 and feeds the MX-AUDIO-01 weight in Plan 02-06.
   - Recommendation (executed): grep happens during `audio.md` authoring (Plan 02-05); MX-AUDIO-01 weight finalized in Plan 02-06 based on grep result.

3. **What is the actual count of paths/ resources?**
   - **Disposition: RESOLVED.** Deferred to Plan 02-03 inventory pass; `load.ts`'s `safeReadDir` will surface the count as part of `ExtractedProject.paths`. If non-zero, Plan 02-06 adds MX-PATH-01.
   - Recommendation (executed): inventoried during Plan 02-03 `loadExtracted` implementation.

4. **Does the catalog tool's autogen-block hash attribute add enough value to justify the complexity?**
   - **Disposition: RESOLVED — DEFERRED.** No `hash=` attribute for v1. Complexity is not justified per RESEARCH original recommendation. Plain `<!-- AUTOGEN:NAME:start --> ... <!-- AUTOGEN:NAME:end -->` markers are used; lint-docs.mjs CHECK mode catches drift via direct content comparison.
   - Recommendation (executed): ship without hash; revisit only if a real-world CI failure surfaces.

5. **Should Phase 3's `tools/server-asset-catalog` be the same binary or a sibling?**
   - **Disposition: RESOLVED — DEFERRED to Phase 3.** Plan 02-04's `runVerify` is hardcoded to `extracted/client-5-8` for Phase 2; Phase 3 will parameterize when the server catalog ships. Carry note already documented in Plan 02-04's `runVerify` source comment ("// For Phase 2: hardcode the input pair (extracted/client-5-8 → docs/extracted-engine). Phase 3 will parameterize when server catalog ships.").
   - Recommendation (executed): Plan 02-04 hardcodes for client-5-8; Phase 3 plan will introduce `--source-name client | server` parameterization without forking the tool.
## Sources

### Primary (HIGH confidence — verified or directly read)
- `npm view phaser dist-tags` (2026-05-02) — `latest: 4.1.0`, `phaser@3.90.0` confirmed
- `npm view pixi.js version` (2026-05-02) — `8.18.1`
- `npm view @pixi/tilemap version` (2026-05-02) — `5.0.2`
- `npm view howler version` (2026-05-02) — `2.2.4`
- `tools/extract-gmd/src/types.ts` (read 2026-05-02) — canonical extraction shapes
- `tools/extract-gmd/src/emit/tree.ts` (read 2026-05-02) — canonical output paths + sanitization patterns
- `tools/extract-gmd/cli.ts` (read 2026-05-02) — CLI shape to mirror
- `tools/extract-gmd/package.json` (read 2026-05-02) — version pins, scripts shape
- `extracted/client-5-8/UNKNOWN-ACTIONS.md` (read 2026-05-02) — exactly two unique action IDs across 27 sites
- `extracted/client-5-8/settings.json` (read 2026-05-02) — game-level settings shape
- `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` (sample, read 2026-05-02) — sprite meta.json shape
- `extracted/client-5-8/objects/0000-server/meta.json`, `0042-player/meta.json`, `0042-player/events/Step.gml`, `0034-title/events/Draw.gml`+`Draw.dnd.json`, `0045-commandob/events/Draw.gml`+`Draw.dnd.json` (read 2026-05-02) — confirms action 523/525 identity
- `extracted/client-5-8/rooms/0003-Main_Menu/meta.json` (sample, read 2026-05-02) — room meta.json shape
- Phase 1 outputs counts: `wc -l` on each subtree (2026-05-02) — 854 sprites, 12 backgrounds, 198 scripts, 320 objects, 16 rooms, 3 datafiles, NO sounds/fonts/timelines
- `.planning/phases/02-client-engine-documentation/02-CONTEXT.md` — locked decisions D-01..D-16
- `.planning/REQUIREMENTS.md` §"Client Engine Documentation (Stage 2)" — CDOC-01..04 acceptance text
- `.planning/ROADMAP.md` Phase 2 section — goals, success criteria, "Standard pattern" research-flag designation
- `.planning/PROJECT.md` — vision, constraints, anti-decisions
- `.planning/research/STACK.md` — Phaser 3 vs PixiJS positioning, Phaser 4 decision-gate language
- `.planning/research/FEATURES.md` — MVP-Critical Stage 6 gate definition (drives `mvp: yes` semantics)
- `.planning/research/ARCHITECTURE.md` — three-pipeline topology, where `tools/asset-catalog` fits
- `.planning/research/PITFALLS.md` §A4, B1, D1, D2, D3, D4 — pitfalls Phase 2 must avoid
- `.planning/codebase/CONCERNS.md` — security/legal context for `admin-anti-port.md`
- `.planning/STATE.md` — Phase 1 plan 01-03/01-05/01-07 validated decisions; tree-size + reproducibility status
- `.planning/phases/01-extraction/01-CONTEXT.md` (referenced via STATE.md) — D-15 determinism, D-19 thin-wrapper authoring
- `decomp/wiki/00-overview.md`, `04-dnd-serialization.md`, `06-gml-syntax-5x.md`, `07-gml-core-functions.md` (read 2026-05-02) — engine background
- `.planning/config.json` — confirms `nyquist_validation: true`, `commit_docs: true`

### Secondary (MEDIUM confidence — WebSearch results, single-source)
- [Phaser TilemapLayer Blend Modes, docs.phaser.io](https://docs.phaser.io/api-documentation/class/tilemaps-tilemap) — confirmed Phaser 3 supports per-layer blend mode + setBlendMode + clearAlpha
- [Phaser BlendModes namespace, photonstorm.github.io](https://photonstorm.github.io/phaser3-docs/Phaser.BlendModes.html) — enumerates supported blend modes (Phaser 3 inherited reference)
- [@pixi/tilemap 5.0.2, npm](https://www.npmjs.com/package/@pixi/tilemap) — confirms requires PixiJS 8.5+; integrates via extension system
- [PixiJS Tilemap api docs, api.pixijs.io](https://api.pixijs.io/@pixi/tilemap/Tilemap.html) — tilemap class extends Container
- [PixiJS releases, github.com/pixijs/pixijs](https://github.com/pixijs/pixijs/releases) — confirms 8.18.0 release (April 2026)

### Tertiary (LOW confidence — needs validation in MATRIX authoring)
- Phaser 3 vs Pixi 8 fine-grained differences for: BMFont vs BitmapText format (need MATRIX cell verification at authoring time), particle system parity, surface_create vs RenderTexture exact API parity. These are MATRIX-row-fill-time questions, not Phase 2 RESEARCH-time blockers.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every version verified via npm at research time; tooling pins inherited verbatim from Phase 1
- Architecture: HIGH — module split mirrors a working Phase 1 tool; cross-ref pattern is well-understood
- Pitfalls: HIGH — derived from concrete inspection of real BNO scripts (Pitfall 1's `Hexport`/`HexportIn` collision is from `objects/0042-player/events/Step.gml`, not hypothetical; Pitfall 3's DnD-args-as-strings is from Phase 1 plan 01-03 W3 LOCKED decision)
- D-08 resolution path: HIGH — both unknown action IDs verified by `.dnd.json` inspection; argument shapes unambiguous
- MATRIX methodology: MEDIUM — methodology is sound but the actual row-by-row scoring is the planner's/author's authoring work, not researched here
- Audio caveat: HIGH (the absence is verified) — but follow-up grep (Open Question 2) determines `audio.md`'s structure
- Phaser 4 caveat: HIGH — verified via npm; STACK.md's own decision-gate language explicitly defers to CDOC-04

**Research date:** 2026-05-02
**Valid until:** 2026-06-02 (30 days for Phaser/Pixi version drift; CDOC-04 outcome lock should occur before this expires)
