# Phase 2: Client Engine Documentation - Pattern Map

**Mapped:** 2026-05-02
**Files analyzed:** 26 (12 TS/scripts/configs + 13 docs + 1 ADR + 1 data extension)
**Analogs found:** 25 / 26 (only `MATRIX.md` matrix-as-data has a partial analog)

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `tools/asset-catalog/package.json` | config | n/a | `tools/extract-gmd/package.json` | exact |
| `tools/asset-catalog/tsconfig.json` | config | n/a | `tools/extract-gmd/tsconfig.json` | exact |
| `tools/asset-catalog/vitest.config.ts` | config | n/a | `tools/extract-gmd/vitest.config.ts` | exact |
| `tools/asset-catalog/cli.ts` | cli | request-response (argv→exit) | `tools/extract-gmd/cli.ts` | exact |
| `tools/asset-catalog/src/types.ts` | model (re-export) | n/a (type-only) | `tools/extract-gmd/src/types.ts` | role-match |
| `tools/asset-catalog/src/load.ts` | service | file-I/O + transform | `tools/extract-gmd/src/extract.ts` (glue), `src/reader/readProjectFile.ts` (aggregator) | role-match |
| `tools/asset-catalog/src/derive.ts` | service | pure transform | `tools/extract-gmd/src/dnd/transcompile.ts` (pure transform style) | role-match |
| `tools/asset-catalog/src/crossref.ts` | service | batch transform | (none — new pattern; closest is `src/dnd/actionLookup.ts` for cached-lookup style) | partial |
| `tools/asset-catalog/src/emit.ts` | service | file-I/O write | `tools/extract-gmd/src/emit/json.ts` + `src/emit/tree.ts` + `src/emit/manifest.ts` | exact |
| `tools/asset-catalog/test/fixtures/mini-extract/` | test fixture | n/a | `tools/extract-gmd/tests/fixtures/build-fixtures.ts` (synthetic-fixture pattern) | role-match |
| `tools/asset-catalog/scripts/lint-docs.mjs` | script (linter) | file-I/O scan | `tools/extract-gmd/scripts/port-action-ids.ts` (one-off CLI script) | role-match |
| `tools/asset-catalog/scripts/lint-matrix.mjs` | script (linter) | file-I/O scan | `tools/extract-gmd/scripts/port-action-ids.ts` | role-match |
| `tools/asset-catalog/scripts/lint-adr.mjs` | script (linter) | file-I/O scan | `tools/extract-gmd/scripts/port-action-ids.ts` | role-match |
| `tools/extract-gmd/data/action-ids.json` | data (extension) | n/a | itself (134 existing entries; D-08 adds 2) | exact |
| `docs/extracted-engine/README.md` | doc (jump table) | n/a | `decomp/wiki/README.md` | exact |
| `docs/extracted-engine/rendering.md` | doc (subsystem narrative) | n/a | `decomp/wiki/07-gml-core-functions.md` (functional cluster + citations) | role-match |
| `docs/extracted-engine/input.md` | doc (subsystem narrative) | n/a | `decomp/wiki/07-gml-core-functions.md` | role-match |
| `docs/extracted-engine/collision.md` | doc (subsystem narrative) | n/a | `decomp/wiki/07-gml-core-functions.md` | role-match |
| `docs/extracted-engine/animation.md` | doc (subsystem narrative) | n/a | `decomp/wiki/07-gml-core-functions.md` | role-match |
| `docs/extracted-engine/scene-room-model.md` | doc (subsystem narrative) | n/a | `decomp/wiki/03-gmd-format.md` (structured-with-citations) | role-match |
| `docs/extracted-engine/save-load.md` | doc (subsystem narrative) | n/a | `decomp/wiki/16-bno-bnb-notes.md` | role-match |
| `docs/extracted-engine/audio.md` | doc (subsystem narrative + finding) | n/a | `decomp/wiki/13-modern-tool-incompat.md` (negative-finding pattern) | role-match |
| `docs/extracted-engine/ui-and-menus.md` | doc (subsystem narrative) | n/a | `decomp/wiki/07-gml-core-functions.md` | role-match |
| `docs/extracted-engine/client-networking.md` | doc (subsystem narrative) | n/a | `decomp/wiki/08-39dll-networking.md` (cross-link target itself) | role-match |
| `docs/extracted-engine/admin-anti-port.md` | doc (anti-port reference) | n/a | (none — new pattern; closest is `decomp/wiki/13-modern-tool-incompat.md` for "rejected with reason" tone) | partial |
| `docs/extracted-engine/unknown-actions-status.md` | doc (resolution status) | n/a | `tools/extract-gmd/src/emit/unknown-actions.ts` table format (mirrored to status form) | role-match |
| `docs/extracted-engine/MATRIX.md` | doc (matrix-as-data) | n/a | (no analog — new pattern; closest is `tools/extract-gmd/data/action-ids.json` for "data is canonical, render from it") | partial |
| `docs/extracted-engine/asset-catalog/index.json` | data (auto-generated) | n/a | `extracted/client-5-8/MANIFEST.sha256` (auto-generated, deterministic) | role-match |
| `docs/extracted-engine/asset-catalog/index.md` | doc (auto-generated table) | n/a | `extracted/client-5-8/UNKNOWN-ACTIONS.md` (auto-generated MD table) | role-match |
| `docs/adr/0001-client-engine.md` | doc (ADR) | n/a | (none — first ADR in project; structure dictated by Michael Nygard format per D-14 + RESEARCH §"ADR Format") | no analog |

---

## Pattern Assignments

### `tools/asset-catalog/package.json` (config)

**Analog:** `tools/extract-gmd/package.json`

**Full file to copy and edit** (lines 1-25):
```json
{
  "name": "asset-catalog",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Phase 2 asset catalog generator + autogen-block writer for docs/extracted-engine/. See decomp/TOOLS.md.",
  "bin": { "asset-catalog": "./cli.ts" },
  "scripts": {
    "catalog": "tsx cli.ts catalog",
    "regen-autogen": "tsx cli.ts regen-autogen",
    "verify": "tsx cli.ts verify",
    "test": "vitest run --exclude 'tests/integration/**'",
    "test:full": "vitest run",
    "test:integration": "vitest run tests/integration/"
  },
  "devDependencies": {
    "typescript": "5.6.3",
    "tsx": "4.21.0",
    "vitest": "4.1.5",
    "@types/node": "25.6.0"
  }
}
```

**Critical conventions to preserve from extract-gmd:**
- `"type": "module"` (ESM throughout — `import.meta.url` patterns depend on this)
- All deps pinned to exact versions (no `^`/`~`) — D-16 determinism mandate
- `"private": true` — never published
- **NO production dependencies** — extract-gmd has only `sharp` for PNG (Phase 6/7 deferred); asset-catalog needs nothing (pure stdlib per RESEARCH §1054 "No production dependencies")
- `bin` field uses `.ts` directly — tsx resolves it

---

### `tools/asset-catalog/tsconfig.json` (config)

**Analog:** `tools/extract-gmd/tsconfig.json`

**Full file to copy verbatim** (lines 1-16) — change only the `include` array:
```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["cli.ts", "src/**/*.ts", "tests/**/*.ts"]
}
```

**Note from RESEARCH (line 1050):** `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` are non-negotiable — they catch real bugs in cross-ref index lookup. Do NOT relax.

**`include` deliberately excludes `scripts/`** — porter scripts (and lint-docs.mjs/lint-matrix.mjs/lint-adr.mjs) sit outside tsconfig because they may be `.mjs` and bypass strict TS (mirrors Phase 1's `port-action-ids.ts` outside-include rationale, IN-09 in `src/dnd/actionLookup.ts:13-17`).

---

### `tools/asset-catalog/vitest.config.ts` (config)

**Analog:** `tools/extract-gmd/vitest.config.ts`

**Full file to copy verbatim** (lines 1-16):
```typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // Single worker so fs writes during tests stay deterministic
    // (golden snapshot tests write to temp dirs and re-read them).
    pool: 'forks',
    maxWorkers: 1,
    include: ['tests/**/*.test.ts'],
    testTimeout: 30000,
  },
});
```

**Why `maxWorkers: 1`:** RESEARCH §1053 — "matches Phase 1 plan 01-01 note about removed `singleFork`". Determinism tests write/re-read temp dirs; parallel workers race.

---

### `tools/asset-catalog/cli.ts` (cli, request-response)

**Analog:** `tools/extract-gmd/cli.ts`

**Imports + shebang pattern** (extract-gmd cli.ts lines 1-16):
```typescript
#!/usr/bin/env node
// tools/asset-catalog/cli.ts
// CLI dispatcher for `asset-catalog`. Subcommands: catalog | regen-autogen | verify | help.
//
// Exit code matrix (Phase 1 D-18 / extract-gmd Plan 06 contract — mirror EXACTLY):
//   0  success
//   1  functional failure (missing input dir, malformed meta.json, drift detected)
//   2  usage error (missing/unknown command, missing required args)

import { fileURLToPath } from 'node:url';
import { runCatalog } from './src/emit.js';
import { runRegenAutogen } from './src/emit.js';
```

**`printUsage()` pattern** (extract-gmd cli.ts lines 18-24) — mirror tone:
```typescript
function printUsage(): void {
  process.stderr.write('Usage:\n');
  process.stderr.write('  asset-catalog catalog <extracted-dir> <docs-out-dir>\n');
  process.stderr.write('  asset-catalog regen-autogen <docs-dir>\n');
  process.stderr.write('  asset-catalog verify <docs-dir>\n');
  process.stderr.write('\n');
  process.stderr.write('See docs/extracted-engine/README.md for the broader pipeline.\n');
}
```

**Switch dispatcher pattern** (extract-gmd cli.ts lines 26-75) — copy structure exactly, swap subcommand names:
```typescript
export async function main(argv: string[]): Promise<number> {
  const [, , cmd, ...rest] = argv;
  if (!cmd) { printUsage(); return 2; }

  switch (cmd) {
    case 'catalog': {
      const [extractedDir, outDir] = rest;
      if (!extractedDir || !outDir) { printUsage(); return 2; }
      try {
        await runCatalog(extractedDir, outDir);
        process.stdout.write(`Cataloged ${extractedDir} -> ${outDir}\n`);
        return 0;
      } catch (e) {
        process.stderr.write(`catalog failed: ${(e as Error).message}\n`);
        return 1;
      }
    }
    // regen-autogen, verify, help follow same try/catch shape
    case 'help':
    case '--help':
    case '-h':
      printUsage();
      return 0;
    default:
      process.stderr.write(`Unknown command: ${cmd}\n`);
      printUsage();
      return 2;
  }
}
```

**Cross-platform invoke-direct guard** (extract-gmd cli.ts lines 82-91) — copy verbatim:
```typescript
// IN-05: align with build-fixtures.ts main() guard — `fileURLToPath(import.meta.url)`
// is portable across POSIX and Windows (where `import.meta.url` is `file:///C:/...`
// while `process.argv[1]` is `C:\...`, so a raw string compare or `endsWith('cli.ts')`
// is fragile). Single canonical check, no false-positive on `not-our-cli.ts`.
const invokedDirectly =
  process.argv[1] !== undefined &&
  fileURLToPath(import.meta.url) === process.argv[1];

if (invokedDirectly) {
  main(process.argv).then(code => process.exit(code)).catch(e => {
    process.stderr.write(`Fatal: ${(e as Error).message}\n`);
    process.exit(1);
  });
}
```

---

### `tools/asset-catalog/src/types.ts` (model, type-only)

**Analog:** `tools/extract-gmd/src/types.ts`

**Re-export pattern** — D-06 tier 1 fields are already canonical in extract-gmd; do NOT redeclare. Re-export and extend:
```typescript
// tools/asset-catalog/src/types.ts
// Source: D-06 (three-tier catalog model). Tier 1 fields come from extract-gmd
// types verbatim — re-export to guarantee zero drift. Tiers 2 and 3 are
// catalog-specific extensions.

export type {
  Sprite, SpriteFrame,
  Background, BackgroundImage,
  Sound,
  Font,
  Script,
  GmObject, ObjectEvent,
  Room, RoomInstance, RoomTile, RoomBackgroundLayer, RoomView,
  Datafile,
  GmPath, PathPoint,
  Timeline, TimelineMoment,
  DnDAction,
  Settings,
  ProjectFile,
} from '../../extract-gmd/src/types.js';

import type { Sprite, Background, Script, GmObject, Room, Datafile } from '../../extract-gmd/src/types.js';

// Tier 2/3 extensions (per RESEARCH §"Three-Tier Catalog Model" lines 258-291):
export interface CatalogedSprite {
  // Tier 1 fields embedded by spread; see RESEARCH lines 261-277 for the verbatim shape.
  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;
  frameCount: number;

  // Tier 2 (derived):
  imageFormat: 'bmp' | 'unknown';
  imageByteLength: number;
  sizeBucket: 'tiny' | 'small' | 'med' | 'large';
  isAnimated: boolean;

  // Tier 3 (cross-ref):
  used_by: {
    objects: number[];
    masks_for: number[];
    scripts: number[];
    rooms: number[];
  };
}
```

**Why relative `../../extract-gmd/src/types.js` (with `.js` extension):** NodeNext module resolution requires explicit `.js` even in `.ts` source. Phase 4+ pnpm workspaces will replace this with `@rebno/extract-gmd` import; until then the relative path is the convention (D-15 in Phase 1 / D-15 in Phase 2 — workspaces deferred to Phase 4).

---

### `tools/asset-catalog/src/load.ts` (service, file-I/O + transform)

**Analog:** RESEARCH.md §"Code Examples — Loading the extracted Project model" (lines 497-543) — already a full skeleton.

**Copy directly from RESEARCH lines 499-543** as starting point. Key elements:
- `readdirSync(...).sort()` for sorted enumeration (D-15 / D-16 determinism)
- One Map per resource type
- Defensive: handle missing dirs (no `sounds/`, no `fonts/`, no `timelines/` per Audio Caveat lines 989-1006) — return empty array, not throw
- Read `MANIFEST.sha256` and embed in `ExtractedProject` so emit/index.json can record input freshness (RESEARCH line 401)

**Defensive missing-dir pattern** (mirror `tools/extract-gmd/src/extract.ts` line 22-32 size-cap defense):
```typescript
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

function safeReadDir(dir: string): string[] {
  if (!existsSync(dir)) return [];          // Audio Caveat: no sounds/ in client-5-8
  if (!statSync(dir).isDirectory()) return [];
  return readdirSync(dir).sort();
}
```

---

### `tools/asset-catalog/src/derive.ts` (service, pure transform)

**Analog:** `tools/extract-gmd/src/dnd/transcompile.ts` (pure-function style — no I/O, takes input, returns derived data)

**Magic-byte detection pattern** — extract-gmd's tree.ts already encodes the BMP magic constant (`0x42 0x4D = "BM"`) at `src/types.ts:79` (Sprite header comment) and `src/emit/tree.ts:41-43`:
```typescript
// tools/asset-catalog/src/derive.ts
// Source: D-06 tier 2 (derived fields). Pure functions; NO I/O.
// Magic byte 0x42 0x4D for BMP per LateralGM GmStreamDecoder.readZlibImage,
// inherited from Phase 1 D-12 + tools/extract-gmd/src/types.ts:79.

export function detectImageFormat(firstBytes: Buffer | null): 'bmp' | 'unknown' {
  if (!firstBytes || firstBytes.length < 2) return 'unknown';
  if (firstBytes[0] === 0x42 && firstBytes[1] === 0x4D) return 'bmp';
  return 'unknown';
}

export function sizeBucket(width: number, height: number): 'tiny' | 'small' | 'med' | 'large' {
  const px = width * height;
  if (px <= 256) return 'tiny';            // ≤ 16×16
  if (px <= 4096) return 'small';          // ≤ 64×64
  if (px <= 65536) return 'med';           // ≤ 256×256
  return 'large';
}

export function audioFormatFromMagic(firstBytes: Buffer): 'wav' | 'mid' | 'mp3' | 'unknown' {
  if (firstBytes.length < 4) return 'unknown';
  if (firstBytes.toString('ascii', 0, 4) === 'RIFF') return 'wav';
  if (firstBytes.toString('ascii', 0, 4) === 'MThd') return 'mid';
  if (firstBytes[0] === 0xFF && (firstBytes[1] & 0xE0) === 0xE0) return 'mp3';
  return 'unknown';
}
```

---

### `tools/asset-catalog/src/crossref.ts` (service, batch transform)

**Analog:** RESEARCH.md §"Cross-ref for a single sprite" (lines 547-595) — full skeleton ready to copy. Plus `tools/extract-gmd/src/dnd/actionLookup.ts` for the Map-cache style if the index gets reused across passes.

**Copy from RESEARCH lines 547-595**, then layer in **Pitfalls 1-4 mitigations** (RESEARCH lines 405-443):

```typescript
// tools/asset-catalog/src/crossref.ts
// Source: D-06 tier 3 (semantic cross-ref). RESEARCH Pitfalls 1-4 are MANDATORY mitigations.

const COMMENT_LINE_RE = /^\s*\/\//;
const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;  // GML 5.x: no nested block comments per decomp/wiki/06-gml-syntax-5x.md

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

function stripComments(code: string): string {
  // Pitfall 2: strip block comments first, THEN line-by-line skip `//` lines
  return code.replace(BLOCK_COMMENT_RE, '');
}

export function spriteMentioned(scriptCode: string, spriteName: string): boolean {
  // Pitfall 1: word-boundary, NOT substring (Hexport != HexportIn)
  const stripped = stripComments(scriptCode);
  const lines = stripped.split('\n').filter(l => !COMMENT_LINE_RE.test(l));
  const re = new RegExp('\\b' + escapeRegex(spriteName) + '\\b');
  return re.test(lines.join('\n'));
}
```

**Pitfall 3 (DnD argument values are strings)** — type-aware Number() conversion when scanning `events/*.dnd.json`:
```typescript
// argType meanings (per Phase 1 plan 01-03 W3 LOCKED, also documented in
// tools/extract-gmd/src/dnd/readAction.ts):
//   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
const RESOURCE_KINDS_FOR_SPRITE = new Set([4]);

export function dndReferencesSprite(action: { argTypes: number[]; argValues: (string|number|boolean)[] }, spriteId: number): boolean {
  for (let i = 0; i < action.argTypes.length; i++) {
    if (RESOURCE_KINDS_FOR_SPRITE.has(action.argTypes[i]!)) {
      if (Number(action.argValues[i]) === spriteId) return true;
    }
  }
  return false;
}
```

**Pitfall 4 (regression test target):** the test fixture `test/fixtures/mini-extract/` MUST include sprites named `Hexport`, `HexportIn`, `HexportOut` and a script that mentions only `HexportIn` — assert `Hexport.used_by.scripts` does NOT include that script (verbatim from RESEARCH line 413 + line 441 cite of `objects/0042-player/events/Step.gml`).

---

### `tools/asset-catalog/src/emit.ts` (service, file-I/O write)

**Analog:** `tools/extract-gmd/src/emit/json.ts` (deterministic-JSON), `tools/extract-gmd/src/emit/tree.ts` (orchestration), `tools/extract-gmd/src/emit/manifest.ts` (POSIX-path manifest), `tools/extract-gmd/src/emit/unknown-actions.ts` (Markdown table emit).

**Deterministic JSON write** — copy verbatim from `tools/extract-gmd/src/emit/json.ts:1-37` (the entire file). Already proven, already tested by `tests/emit/json.test.ts`. Do NOT reimplement.

```typescript
// tools/asset-catalog/src/emit.ts
// Source: D-16 (deterministic output, mirror Phase 1 D-15).
// Re-import from extract-gmd's emit/json.ts? No — it's not a workspace package
// yet (Phase 4 introduces workspaces per D-15). Inline-copy the proven 30 lines.

import { writeFileSync } from 'node:fs';

export function stringifySortedJson(value: unknown): string {
  return JSON.stringify(sortKeysRecursive(value), null, 2);
}

export function writeJsonDeterministic(path: string, value: unknown): void {
  const json = stringifySortedJson(value) + '\n';
  // Force LF on Windows hosts (Phase 1 IN-06 / extract-gmd manifest.ts:74-78)
  writeFileSync(path, json.replace(/\r\n/g, '\n'), { encoding: 'utf8' });
}

function sortKeysRecursive(v: unknown): unknown {
  if (Array.isArray(v)) return v.map(sortKeysRecursive);
  if (v !== null && typeof v === 'object') {
    if (Buffer.isBuffer(v)) return v;
    const o = v as Record<string, unknown>;
    const sortedKeys = Object.keys(o).sort();
    const out: Record<string, unknown> = {};
    for (const k of sortedKeys) out[k] = sortKeysRecursive(o[k]);
    return out;
  }
  return v;
}
```

**Markdown table emission** — copy line-array pattern from `tools/extract-gmd/src/emit/unknown-actions.ts:43-56`:
```typescript
const lines: string[] = [];
lines.push('# Asset Catalog');
lines.push('');
lines.push('Auto-generated from extracted/<src>/. Do not hand-edit.');
lines.push('');
lines.push('| ID | Name | Format | Size | Used by (objects) |');
lines.push('|----|------|--------|------|-------------------|');
for (const sp of sortedSprites) {
  lines.push(`| ${sp.id} | ${sp.name} | ${sp.imageFormat} | ${sp.sizeBucket} | ${sp.used_by.objects.join(', ') || '—'} |`);
}
writeFileSync(path, lines.join('\n') + '\n', 'utf8');
```

**Determinism: NO `Date.now()` or `new Date()`** — RESEARCH line 733: "Lint rule recommended: grep for `Date` in `src/emit/` rejects any match." Mirror Phase 1's JSON-05 negative test (`tools/extract-gmd/tests/emit/json.test.ts:48-54`):
```typescript
it('catalog emit/ source contains zero Date.now() references', () => {
  const src = readFileSync(new URL('../../src/emit.ts', import.meta.url), 'utf8');
  const noComments = src.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
  expect(noComments).not.toMatch(/Date\.now/);
  expect(noComments).not.toMatch(/new\s+Date\b/);
});
```

**Autogen-block in-place rewrite** — RESEARCH §"Pattern 2" lines 311-333. Use a single regex per AUTOGEN block name; replace between markers; if identical, skip (don't churn mtime). Pattern is RESEARCH lines 329-334 verbatim.

---

### `tools/asset-catalog/test/fixtures/mini-extract/` (test fixture)

**Analog:** `tools/extract-gmd/tests/fixtures/build-fixtures.ts` (synthetic-fixture pattern — programmatically construct a tiny project).

**Note:** extract-gmd builds a **binary `.gmd` fixture** with `BufferWriter` primitives. Phase 2 fixtures are **already-extracted directory trees**, so the analog is "synthesize an extracted/ tree", not "synthesize a .gmd". Use a script `tests/fixtures/build-mini-extract.ts` that mkdirp's a tiny tree:

```
test/fixtures/mini-extract/
├── settings.json
├── MANIFEST.sha256                    (synthesized, not Phase 1's real one)
├── sprites/
│   ├── 0000-Foo/{meta.json, frames/img_000.bmp}
│   ├── 0001-FooBar/{meta.json, frames/img_000.bmp}      ← Pitfall 1 collision target
│   ├── 0002-FooBaz/{meta.json, frames/img_000.bmp}      ← Pitfall 1 collision target
│   ├── 0003-Hexport/{meta.json, frames/img_000.bmp}      ← regression cite RESEARCH line 407
│   ├── 0004-HexportIn/{meta.json, frames/img_000.bmp}
│   ├── 0005-HexportOut/{meta.json, frames/img_000.bmp}
│   └── 0006-HexportMask/{meta.json, frames/img_000.bmp}
├── scripts/
│   └── 0000-uses_foobar_only.gml      ← single GML line: "if (sprite_index == FooBar) {}"
└── objects/
    └── 0042-player/                   ← mirrors the real BNO object 0042 cited in RESEARCH
        ├── meta.json                  ← spriteId=3 (Hexport), maskId=6 (HexportMask)
        └── events/Step.dnd.json       ← DnD action with argType=4, argValue="3" (Hexport)
```

**Required regression assertions** (mapped to RESEARCH validation table lines 1104-1105):
1. Sprite `Foo` (id 0) `used_by.scripts` does NOT include script 0 (the script mentions only `FooBar`).
2. Sprite `FooBar` (id 1) `used_by.scripts` DOES include script 0.
3. Object 0042's spriteId=3 → sprite `Hexport.used_by.objects` includes 0042.
4. Object 0042's maskId=6 → sprite `HexportMask.used_by.masks_for` includes 0042.

**Build approach:** use plain `mkdirSync` + `writeFileSync` from a `build-mini-extract.ts` script (mirrors extract-gmd's build-fixtures.ts orchestration); commit the **tree** (not the script output) so tests don't depend on a build step.

---

### `tools/asset-catalog/scripts/lint-docs.mjs` (script, file-I/O scan)

**Analog:** `tools/extract-gmd/scripts/port-action-ids.ts` (one-off CLI script outside tsconfig include)

**Header comment pattern** (extract-gmd port-action-ids.ts lines 1-15):
```javascript
#!/usr/bin/env node
// tools/asset-catalog/scripts/lint-docs.mjs
// Source: RESEARCH §"Pattern 2 — Hand-Authored + Autogen Hybrid" (lines 294-333).
// Pitfall mitigation: catches manual edits to <!-- AUTOGEN:NAME:start ... --> blocks
// that bypass the regen-autogen tool.
//
// Usage:
//   node tools/asset-catalog/scripts/lint-docs.mjs docs/extracted-engine/  # check
// Exit codes:
//   0 success, all blocks match
//   1 functional failure (autogen drift detected)
//   2 usage error
```

**Why `.mjs` not `.ts`:** RESEARCH line 1051's IN-09 rationale — porter scripts sit outside tsconfig `include` so they can run without the rest of the project being typecheck-clean. Either `.mjs` (no transpile) or invoke via tsx; planner's call. Recommendation: `.mjs` for these three linters because they have no shared types with src/.

**Round-trip check pattern** (extract-gmd port-action-ids.ts uses `--check` mode — same posture):
- Read all `*.md` under target dir.
- For each AUTOGEN block, recompute expected content (call into `src/emit.ts` exports).
- Compare with on-disk content; report drift on stderr; exit 1 on any drift.

**Repo-level glue** (RESEARCH lines 1058-1062):
```json
// root package.json scripts addition
"catalog:client": "tsx tools/asset-catalog/cli.ts catalog extracted/client-5-8 docs/extracted-engine",
"catalog:all": "pnpm catalog:client",
"catalog:lint": "node tools/asset-catalog/scripts/lint-docs.mjs docs/extracted-engine && node tools/asset-catalog/scripts/lint-matrix.mjs docs/extracted-engine/MATRIX.md && node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md"
```

---

### `tools/asset-catalog/scripts/lint-matrix.mjs` (script)

**Analog:** `tools/extract-gmd/scripts/port-action-ids.ts` (--check mode pattern)

**Validation rules** (per RESEARCH §"MATRIX Methodology" lines 822-859 + Pitfall 5 line 451):
1. `MATRIX-rows.json` exists at `docs/extracted-engine/MATRIX-rows.json`.
2. Each row matches schema (RESEARCH lines 829-841): `{ rowId, subsystem, feature, bnoUsage, weight, cite, scores: { 'phaser-3.90', 'phaser-4.1', 'pixi-8.18' } }`.
3. Three engine columns present in MATRIX.md (RESEARCH validation row CDOC-03 line 1106).
4. Rendered weighted totals in MATRIX.md match Σ(row.weight × score(row, engine)) computed from JSON.
5. All `rowId`s in MATRIX.md exist in MATRIX-rows.json (Pitfall 6 mitigation).

**No external YAML/JSON parser dependency:** RESEARCH line 382 — "5-line regex parser is sufficient. Avoid pulling in `js-yaml`."

---

### `tools/asset-catalog/scripts/lint-adr.mjs` (script)

**Analog:** `tools/extract-gmd/scripts/port-action-ids.ts`

**Validation rules** (per RESEARCH §"ADR Format" lines 924-963 + Pitfall 6 line 461):
1. `docs/adr/0001-client-engine.md` exists.
2. Contains all four required headers in order: `## Status`, `## Context`, `## Decision`, `## Consequences` (D-14 + Michael Nygard format per RESEARCH §"Don't Hand-Roll" line 380).
3. `## References` section contains at least one `MX-*` rowId citation.
4. Every `MX-*` rowId cited in the ADR resolves to a row in `docs/extracted-engine/MATRIX-rows.json` (Pitfall 6 + RESEARCH validation row CDOC-04 line 1109).

**Implementation:** read ADR → regex `/MX-[A-Z]+-\d+/g` → check each ID against the parsed JSON. Exit 1 on any unresolved ID.

---

### `tools/extract-gmd/data/action-ids.json` (extension)

**Analog:** itself (existing 134 entries; D-08 path (a) appends 2)

**Existing format** (action-ids.json lines 1-30 — `id 101 "Move"` exemplar):
```json
[
  {
    "argCount": 2,
    "argKinds": [1, 0],
    "description": "Start moving in one of selected directions",
    "gmlTemplate": "action_move(@0, @1)",
    "id": 101,
    "name": "Move"
  },
  ...
]
```

**Two new entries to append** (per RESEARCH §"D-08 Resolution Strategy" lines 964-987 + §"Action_ID 523/525 resolution" lines 624-648):

**Entry for ID 523** (16 sites; LateralGM "Set font"):
```json
{
  "argCount": 6,
  "argKinds": [1, 0, 0, 0, 0, 0],
  "description": "Set font for text drawing",
  "gmlTemplate": "draw_set_font(@0); draw_set_color(@2); draw_set_halign(@3); draw_set_valign(@4)",
  "id": 523,
  "name": "Set_Font"
}
```

**Entry for ID 525** (11 sites; "Set font (combined)" composite arg):
```json
{
  "argCount": 1,
  "argKinds": [1],
  "description": "Set font for text drawing (combined: name,size,color,bold,italic,halign,valign)",
  "gmlTemplate": "draw_set_font_combined(@0)",
  "id": 525,
  "name": "Set_Font_Combined"
}
```

**Provenance step (D-08 path (a) full procedure):**
1. Re-run `pnpm tsx tools/extract-gmd/scripts/port-action-ids.ts` against LateralGM 1.8.234 — confirm IDs 523/525 are missing from the auto-port (RESEARCH line 975 hypothesizes they're in an unparsed `.lgl` library file or filtered).
2. If missing from upstream: hand-author the two entries with the `.dnd.json` signatures verified in RESEARCH lines 627-637; document the hand-author in `tools/extract-gmd/data/README.md` provenance section.
3. Re-run `pnpm extract` on `legacy/open-source-release/BN Online Client 5-8.gmd` → assert `extracted/client-5-8/UNKNOWN-ACTIONS.md` is no longer emitted (zero unknown IDs).
4. Update `data/README.md` to reflect new count: 134 → 136 entries.

**Sort order:** the JSON is sorted by `id` ascending (per `data/README.md:30` "The array is sorted by `id` ascending"). 523 and 525 slot at the end (after the existing max ID, which is < 523 in current 134-entry list).

---

### `docs/extracted-engine/README.md` (doc, jump table)

**Analog:** `decomp/wiki/README.md` (lines 1-46) — "Start here by task" jump table.

**Title + intro pattern** (decomp/wiki/README.md lines 1-3):
```markdown
# Original BN Online Client Engine — Extracted Documentation

Searchable subsystem-keyed documentation of the BNO 5-8 client engine, extracted from `BN Online Client 5-8.gmd` via Phase 1.

## Start here by task
```

**Task table pattern** (decomp/wiki/README.md lines 7-19) — apply to BNO subsystems:
```markdown
| Task | Read |
|---|---|
| "How does the player move?" | [input](input.md) → [collision](collision.md) |
| "How are sprites drawn / what fonts does the engine use?" | [rendering](rendering.md) |
| "How do rooms work and what triggers a room transition?" | [scene-room-model](scene-room-model.md) |
| "How does the client talk to the server?" | [client-networking](client-networking.md) → [decomp/wiki/08-39dll-networking](../../decomp/wiki/08-39dll-networking.md) |
| "What does the original admin model look like (so we can NOT port it)?" | [admin-anti-port](admin-anti-port.md) |
| "Which Phaser/Pixi feature gaps drove the engine choice?" | [MATRIX](MATRIX.md) → [adr/0001-client-engine](../adr/0001-client-engine.md) |
| "Which assets are in the project?" | [asset-catalog/index.md](asset-catalog/index.md) |
| "Are there unresolved DnD actions?" | [unknown-actions-status](unknown-actions-status.md) |
```

**Convention footer** (decomp/wiki/README.md lines 43-46):
```markdown
## Convention

Cross-refs use relative `.md` links. Per D-19 thin-wrapper pattern: where the `decomp/wiki/` already covers a topic (e.g. 39dll, GML core functions), this tree links into the wiki rather than duplicating it.
```

---

### Subsystem narrative MDs (rendering, input, collision, animation, scene-room-model, save-load, audio, ui-and-menus, client-networking)

**Analog:** `decomp/wiki/07-gml-core-functions.md` (functional-cluster pattern with citations); structure also informed by `decomp/wiki/03-gmd-format.md`.

**Common structure for ALL subsystem MDs** (per RESEARCH §"Hand-Authored + Autogen Hybrid Pattern" lines 758-792):

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

# <Subsystem name>

[2-4 paragraphs of hand-authored prose: how the engine handles this subsystem,
 the canonical GML idioms BNO uses, BNO-specific quirks that the rebuild MUST
 replicate. Quote 5-30 line GML snippets per D-10 — never reverse every line.]

## Key idioms (hand-authored)

[bulleted list of canonical patterns with inline GML quotes]

## Scripts referenced in this subsystem

<!-- AUTOGEN:scripts:start hash=<computed> -->
| Script ID | Name | Used in objects | Notes |
|-----------|------|-----------------|-------|
| ... emitted by tools/asset-catalog regen-autogen ... |
<!-- AUTOGEN:scripts:end -->

## Objects referenced in this subsystem

<!-- AUTOGEN:objects:start hash=<computed> -->
| Object ID | Name | Sprite | Notes |
|-----------|------|--------|-------|
| ... emitted by tools/asset-catalog regen-autogen ... |
<!-- AUTOGEN:objects:end -->

## Engine functions used

<!-- AUTOGEN:gml-functions:start hash=<computed> -->
| GML function | Call sites | Sample script | Wiki link |
|--------------|-----------|---------------|-----------|
| ... emitted by tools/asset-catalog regen-autogen ... |
<!-- AUTOGEN:gml-functions:end -->

## Rebuild guidance (hand-authored)

[what to preserve verbatim, what to modernize, what feeds MATRIX rows]

## See also
- decomp/wiki/<relevant>.md
- (cross-links to sibling subsystem MDs)
```

**Per-subsystem source-material map:** RESEARCH §"File List & Source-Material Map" lines 740-756 enumerates the primary script and object IDs for each subsystem MD — this is the planner's input list. Highlights:

| Subsystem | Primary GML/object input | mvp tag |
|-----------|---------------------------|---------|
| rendering | scripts 2, 263-269, 298, 303, 311; objects 34, 45, 118, 223 (action 523/525) | yes |
| input | object 0042-player Keyboard-37/38/39/40 events | **yes** (CLI-08 movement) |
| collision | object 0042-player Step.gml uses `collision_rectangle`; Collision-N events on 0042 | **yes** (CLI-08 walking) |
| animation | object 0042-player Create.gml `image_speed = 0.8`, Alarm-1..-5 | yes |
| scene-room-model | search scripts for `room_goto`, `room_restart`; all 16 rooms | **yes** (CLI-08 = "one room") |
| save-load | search for `file_bin_*`, `file_text_*`, `ini_*` | no (Phase 3 owns server saves) |
| audio | search for `sound_play|playmidi|external_define.*sound`; **headline finding: no sounds/ dir** | no |
| ui-and-menus | rooms Main_Menu/Online_Lobby/Settings_Menu/Online_Command_Screen + their objects | partial (chat HUD = yes; menus = no for MVP) |
| client-networking | scripts 14-26 (TCP setup), 23-25 (send/recv/peek); thin wrapper into decomp/wiki/08-39dll-networking.md | **yes** (CLI-08 = chat) |

**Code-snippet citation format** (per D-03 + decomp/wiki/07-gml-core-functions.md style):
````markdown
The Step event for player movement (`objects/0042-player/events/Step.gml` lines N-M):

```gml
// preserve original GML verbatim
if (sprite_index != Hexport && sprite_index != HexportIn && ...) {
  ...
}
```

This pattern matches the canonical GM5 `image_index`/`sprite_index` swap-animation idiom (see [decomp/wiki/07-gml-core-functions.md](../../decomp/wiki/07-gml-core-functions.md)).
````

---

### `docs/extracted-engine/admin-anti-port.md` (doc, anti-port reference)

**Analog:** None direct. Closest tonal analog is `decomp/wiki/13-modern-tool-incompat.md` ("rejected with reason" structure for tools that won't work).

**Required structure** (per D-02):
```markdown
---
mvp: no
subsystem: admin
status: anti-port-reference
---

# BNO Admin Model — Anti-Port Reference

> **REJECTED-AS-PORTED.** The original admin model (`Ctrl+E` clipboard RCE,
> `Ctrl+Q` inspect, `,ServerCommands.txt`, `Ctrl+O Codes.txt`) is documented
> here as a forcing function for Phase 7 PAR-07 (modernized authenticated web UI).
> Each command/keybind below carries an explicit rejection reason.
> See CLAUDE.md hard rule #3 for the security rationale.

## Keybinds (catalogued from legacy/open-source-release/,ServerCommands.txt and legacy/servers/enlyzeam-current/Ctrl+O Codes.txt)

| Keybind | Original behavior | REJECTED-AS-PORTED — reason |
|---------|-------------------|------------------------------|
| Ctrl+E | Execute clipboard contents as GML in admin context | Arbitrary code execution from clipboard = remote code execution as the operator. Replaced by authenticated web UI form (PAR-07) with curated action set. |
| Ctrl+Q | Inspect object/instance under cursor | Tied to in-client privilege. Replaced by separate read-only admin web view. |
| Ctrl+O | Run command from `Codes.txt` lookup | Plaintext credential file in cwd. Replaced by argon2id-authenticated session (CLAUDE.md hard rule #2). |
| ... (one row per command surface in source files) | ... | ... |

## Server commands (catalogued from ,ServerCommands.txt)

| Command | Original behavior | REJECTED-AS-PORTED — reason |
| ... | ... | ... |

## Forcing function for Phase 7 PAR-07

When PAR-07 designs the modernized admin UI, every entry in this document MUST be
addressed (either reimplemented in the new UI or explicitly DROPPED with reason).
Phase 7 sign-off gate: a 1:1 traceability table from this document to the new UI.
```

**Source files to enumerate from** (read-only inputs per CONTEXT lines 137-140):
- `legacy/open-source-release/,ServerCommands.txt`
- `legacy/servers/enlyzeam-current/Ctrl+O Codes.txt`

---

### `docs/extracted-engine/unknown-actions-status.md` (doc)

**Analog:** `tools/extract-gmd/src/emit/unknown-actions.ts:43-56` (Markdown table emit pattern), tone mirrored to "resolution status" form.

**Path (a) outcome** (RESEARCH lines 977-987 verbatim):
```markdown
---
mvp: no
subsystem: meta
---

# Unknown DnD Action ID Resolution Status

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

After resolution: `extracted/client-5-8/UNKNOWN-ACTIONS.md` is no longer emitted (zero unknown IDs in source after `pnpm extract:all` re-run).

## Verification

```sh
# Should produce zero matches:
grep -r "UNKNOWN ACTION_ID" extracted/client-5-8/
```

## Path (b) fallback (NOT CHOSEN per RESEARCH §"D-08 Resolution Strategy" line 973)

If for any future ID, path (b) is taken instead, format the row as:
| <id> | DEFERRED | <reason — e.g. "no LateralGM entry; Phase 6 will defer this Draw event"> |
```

---

### `docs/extracted-engine/MATRIX.md` (doc, matrix-as-data)

**Analog:** None direct. Closest is `tools/extract-gmd/data/action-ids.json` for the "data is canonical, render from it" discipline.

**Structure** (RESEARCH §"MATRIX Methodology" + ADR §"References" lines 958-961):
```markdown
---
mvp: no
subsystem: matrix
---

# Client Engine Feature Matrix — Phaser 3.90 vs Phaser 4.1 vs PixiJS 8.18

> **Source of truth:** `MATRIX-rows.json` (this file is rendered from it; see
> tools/asset-catalog regen-autogen). Do NOT hand-edit the rows below — edit
> the JSON and re-run.

## Methodology
- Grade per cell: `native` (4) / `plugin` (3) / `manual` (2) / `hard` (1) per PITFALLS D3.
- Weighted score per engine = Σ(row.weight × score(row, engine)).
- Hard-knockout per D-13: any row with weight ≥ 4 graded `hard` for Phaser 3 → ADR flips to PixiJS.

## Weights

<!-- AUTOGEN:matrix-weights:start -->
| Subsystem | Sum of weights |
| ... rendered from MATRIX-rows.json ... |
<!-- AUTOGEN:matrix-weights:end -->

## Rows

<!-- AUTOGEN:matrix-rows:start -->
| Row ID | Subsystem | Feature | Weight | Phaser 3.90 | Phaser 4.1 | PixiJS 8.18 | Cite |
| ... rendered from MATRIX-rows.json ... |
<!-- AUTOGEN:matrix-rows:end -->

## Totals

<!-- AUTOGEN:matrix-totals:start -->
| Engine | Weighted total |
|--------|----------------|
| Phaser 3.90 | (computed) |
| Phaser 4.1 | (computed) |
| PixiJS 8.18 | (computed) |
<!-- AUTOGEN:matrix-totals:end -->
```

**Sidecar file:** `docs/extracted-engine/MATRIX-rows.json` — the canonical data per RESEARCH lines 854-858. Schema is the `MatrixRow` TS interface at RESEARCH lines 829-841.

**Suggested initial row list:** RESEARCH lines 864-887 — 21 rows pre-drafted with sketched weights. Planner finalizes after subsystem MDs are drafted.

---

### `docs/extracted-engine/asset-catalog/index.json` (data, auto-generated)

**Analog:** `extracted/client-5-8/MANIFEST.sha256` (auto-generated, deterministic, regen-on-demand).

**Determinism contract** mirror Phase 1 D-15 / D-16 (RESEARCH §"Determinism Strategy" lines 725-736):
- Recursive sort all object keys (`sortKeysRecursive` from emit.ts).
- 2-space indent, single trailing `\n`, LF only on Windows hosts.
- No timestamps, no absolute paths.
- Embed `MANIFEST.sha256` content from input as a freshness marker (RESEARCH line 401) — top-level field `inputManifestSha256`.

**Top-level shape** (catalog model — see `src/types.ts` extension):
```json
{
  "inputManifestSha256": "...",
  "summary": {
    "spriteCount": 854,
    "backgroundCount": 12,
    "objectCount": 320,
    "scriptCount": 198,
    "roomCount": 16,
    "soundCount": 0,
    "fontCount": 0,
    "timelineCount": 0
  },
  "sprites": [ /* CatalogedSprite[] sorted by id */ ],
  "backgrounds": [ ... ],
  "objects": [ ... ],
  "scripts": [ ... ],
  "rooms": [ ... ]
}
```

---

### `docs/extracted-engine/asset-catalog/index.md` (doc, auto-generated)

**Analog:** `extracted/client-5-8/UNKNOWN-ACTIONS.md` (the only existing auto-generated MD — emitted by `tools/extract-gmd/src/emit/unknown-actions.ts:33-57`).

**Header pattern** (unknown-actions.ts lines 43-48):
```markdown
# Asset Catalog — extracted/client-5-8/

Auto-generated by `tools/asset-catalog`. Do not hand-edit. Re-run with:
`pnpm catalog:client`.

Input manifest: <inputManifestSha256 prefix>...
```

**Per-resource table** (mirror unknown-actions.ts:49-53):
```markdown
## Sprites (854)

| ID | Name | Format | Frames | Size bucket | Used by (objects) | Used by (scripts) |
|----|------|--------|--------|-------------|-------------------|-------------------|
| 0 | Hexport | bmp | 4 | small | 42 | 42, 67 |
| ... |
```

---

### `docs/adr/0001-client-engine.md` (ADR, first in project)

**Analog:** None — first ADR. Format dictated by D-14 + RESEARCH §"ADR Format + Citation Discipline" lines 924-963 (Michael Nygard format).

**Copy the template at RESEARCH lines 928-962 verbatim** — it is already written-to-spec including:
- Status / Date / Phase header
- Context section (cites README.md + MATRIX.md)
- Decision section (cites specific MX-* row IDs — Pitfall 6 enforcement)
- Consequences section (Phase 6 commitments: AST-01 atlas format, AST-02 audio format, AST-03 font format)
- References section with concrete MX-* row IDs and external migration guide URL

**Phaser 4 coverage requirement** (RESEARCH §"Phaser 4 Caveat" lines 908-922): the Decision section MUST explicitly address why v3 over v4 (or vice versa); Consequences section MUST note migration cost if Phase 7 wants to flip later.

---

## Shared Patterns

### Pattern S-1: Determinism (D-16, mirrors Phase 1 D-15)

**Source:** `tools/extract-gmd/src/emit/json.ts:1-37` (the entire 30-line file).
**Apply to:** every file under `tools/asset-catalog/src/emit.ts` and any output written under `docs/extracted-engine/asset-catalog/`.

**The five non-negotiables** (Phase 1 IN-06 + extract-gmd json.ts header):
1. `JSON.stringify(value, null, 2)` — 2-space indent.
2. Single trailing `'\n'`.
3. `.replace(/\r\n/g, '\n')` post-stringify — Windows-host LF safety.
4. NO `Date.now()` / `new Date()` anywhere in emit code (negative test in `tests/emit/json.test.ts:48-54`).
5. Recursive key sort at every depth (`sortKeysRecursive` per json.ts:23-37).

**Test mirror:** Phase 1 JSON-04 round-trip (`tests/emit/json.test.ts:29-46`):
```typescript
writeJsonDeterministic(p, value);
const buf1 = readFileSync(p);
writeJsonDeterministic(p, value);
const buf2 = readFileSync(p);
expect(Buffer.compare(buf1, buf2)).toBe(0);
expect(buf1.includes(0x0d)).toBe(false);   // no \r anywhere
```

---

### Pattern S-2: CLI exit code matrix (Phase 1 D-18 / extract-gmd Plan 06)

**Source:** `tools/extract-gmd/cli.ts:1-91` — the entire file.
**Apply to:** `tools/asset-catalog/cli.ts` (the only new CLI). Lint scripts (`lint-*.mjs`) follow the same 0/1/2 convention.

**Exit code contract:**
- `0` = success
- `1` = functional failure (parse error, drift detected, missing input dir, malformed meta.json)
- `2` = usage error (no command, unknown command, missing required args)

**Cross-platform `invokedDirectly` guard** — extract-gmd cli.ts:82-91 (already quoted above). Mandatory; raw string compare on `process.argv[1]` is fragile on Windows.

**Test mirror:** Phase 1 CLI-01 through CLI-05 (`tools/extract-gmd/tests/integration/cli-extract.test.ts:87-134`) — copy the suite shape exactly:
- CLI-01: no command → exit 2 + Usage on stderr
- CLI-02: happy path → exit 0
- CLI-03: chained verify → exit 0
- CLI-04: drift → exit 1 + drift message on stderr
- CLI-05: unknown command → exit 2

---

### Pattern S-3: Cross-OS path handling

**Source:** `tools/extract-gmd/src/emit/manifest.ts:38-46` and `:71-78`.
**Apply to:** any file in `src/load.ts` or `src/emit.ts` that handles paths from the extracted tree.

**Two non-negotiables** (Phase 1 IN-06 + manifest.ts comments):
1. Strip path component to POSIX form before storage: `posix.normalize(relative(treeDir, f).replace(/\\/g, '/'))`.
2. When parsing committed text files cross-OS, strip trailing `\r` per line: `rawLine.replace(/\r$/, '')` (extract-gmd manifest.ts:74-78 — the IN-06 comment block explains the failure mode of skipping this step).

---

### Pattern S-4: Defensive missing-input handling

**Source:** `tools/extract-gmd/src/extract.ts:22-32` (size cap) + `tools/extract-gmd/src/verify.ts:21-42` (try/catch around missing dirs).
**Apply to:** `src/load.ts` (RESEARCH §"Audio Caveat" — extracted/client-5-8 has NO `sounds/`, `fonts/`, `timelines/` dirs).

**Pattern:**
```typescript
function safeReadDir(dir: string): string[] {
  if (!existsSync(dir)) return [];          // missing is normal (Audio Caveat)
  if (!statSync(dir).isDirectory()) return [];
  return readdirSync(dir).sort();
}
```

**Why not throw:** RESEARCH §"Audio Caveat" lines 989-1006 confirms missing `sounds/`, `fonts/`, `timelines/` is the actual current state of `extracted/client-5-8/`. Throwing would block the entire pipeline; returning `[]` lets the catalog correctly report `soundCount: 0`.

---

### Pattern S-5: Thin-wrapper documentation (Phase 1 D-19)

**Source:** `tools/extract-gmd/README.md:1-21` (links into wiki rather than duplicating).
**Apply to:** `tools/asset-catalog/README.md` AND every subsystem MD under `docs/extracted-engine/`.

**The discipline** (Phase 1 D-19 + RESEARCH line 1055):
- Don't duplicate `decomp/wiki/` content.
- Link to it: every subsystem MD's "See also" section cites the relevant wiki page (rendering → wiki/05 + wiki/07; collision → wiki/07; client-networking → wiki/08; save-load → wiki/16; etc.).
- Tool README is a 20-line wrapper pointing into the broader plan/wiki ecosystem.

**Quote from extract-gmd/README.md (the entire file):**
```markdown
# extract-gmd

GameMaker 5.3a `.gmd` extractor — TS port of LateralGM `GmFileReader` semantics.

See `../../decomp/TOOLS.md` for the era-appropriate decompiler stack and the rank-1..4 procedure.
See `../../decomp/wiki/03-gmd-format.md` for the binary format spec.
See `../../decomp/wiki/15-extraction-pipeline.md` for the broader pipeline.

## Usage
... (4 lines)

## Determinism
... (3 lines)
```

Phase 2's `tools/asset-catalog/README.md` should be ~20 lines, structured identically.

---

### Pattern S-6: Re-export types over redeclare

**Source:** `tools/extract-gmd/src/dnd/actionLookup.ts:18-26` (LibAction interface duplicated outside tsconfig in port-action-ids.ts:74-80, with explicit IN-09 comment explaining the duplication).
**Apply to:** `tools/asset-catalog/src/types.ts` (re-exports from extract-gmd/src/types.ts).

**The discipline:** Phase 1 reluctantly duplicates `LibAction` between `actionLookup.ts` and the porter script because the porter sits **outside tsconfig include**. For asset-catalog → extract-gmd, the import is **inside tsconfig**, so re-export (`export type { Sprite } from '../../extract-gmd/src/types.js'`) is the right answer — guaranteed zero drift.

**B3 canonical field names are LOCKED** (extract-gmd/src/emit/tree.ts:20-26 + types.ts:55-280):
- `sound.audioBytes` / `sound.audioExt` (NOT `data` / `ext`)
- `sprite.frames[i].imageBytes` / `imageFormat` (Plan 07 Option A — opaque BMP)
- `background.image.imageBytes` (NOT flat `pixels`)
- `font.glyphs.imageBytes`
- `datafile.bytes` (NOT `data`)

Asset-catalog MUST consume these names verbatim.

---

## No Analog Found

| File | Role | Reason | Fallback Pattern |
|------|------|--------|------------------|
| `docs/adr/0001-client-engine.md` | ADR | First ADR in project | Michael Nygard format per D-14; full template at RESEARCH lines 928-962 |
| `docs/extracted-engine/MATRIX.md` (matrix-as-data) | doc | No prior matrix doc in repo | Inspired by `tools/extract-gmd/data/action-ids.json` discipline (data is canonical; render from it). Schema at RESEARCH lines 829-841. |
| `docs/extracted-engine/admin-anti-port.md` | doc (anti-port reference) | New documentation pattern (D-02) | Tonal cue from `decomp/wiki/13-modern-tool-incompat.md`; structure designed from D-02 + CLAUDE.md hard rule #3 |
| `tools/asset-catalog/src/crossref.ts` | service | No prior cross-ref code | Full skeleton at RESEARCH lines 547-595; pitfall mitigations at RESEARCH lines 405-443 |

---

## Metadata

**Analog search scope:**
- `tools/extract-gmd/` — all source, tests, scripts, configs, data (Phase 1 deliverable)
- `decomp/wiki/` — 17 docs (RE knowledge base — narrative-doc analogs)
- `extracted/client-5-8/` — auto-generated MD example (UNKNOWN-ACTIONS.md)
- `.planning/research/` — searched but project-level research is not a code analog (it's an upstream input)

**Files scanned:** 27 read directly + 80+ via Glob/Grep enumeration

**Pattern extraction date:** 2026-05-02

**Key cross-cutting principles inherited from Phase 1:**
1. Determinism is non-negotiable (D-15 Phase 1 → D-16 Phase 2).
2. Headless / CI-runnable / no GUI dependencies on the daily-dev path.
3. Tool README = thin wrapper into the broader wiki/plan ecosystem (D-19).
4. Strict TS with `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes`.
5. Cross-OS POSIX paths in committed output; LF line endings everywhere.
6. Test fixtures committed alongside builders (Phase 1's `tests/fixtures/build-fixtures.ts` posture).
