# Phase 3: Server Documentation & Schemas - Pattern Map

**Mapped:** 2026-05-03
**Files analyzed:** 35 (12 TS/scripts/configs in two new tools + 1 new package + 13 docs + 2 ADRs + 4 lint scripts + 2 errata + 1 root package.json edit)
**Analogs found:** 33 / 35 (Drizzle `tables.ts` and `0001_baseline.sql` have no in-repo analog — first DB code in the project; covered by RESEARCH.md §Drizzle Schema Draft + Drizzle docs)

Phase 3 outputs are dominated by *parallels* of Phase 2 outputs against the server tree, plus three first-of-kind artifacts (Drizzle schema, two new ADRs, parity-checklist data file). Every TS file under `tools/` has an exact analog in `tools/asset-catalog/` or `tools/extract-gmd/`. Every doc under `docs/extracted-server/` has a direct parallel in `docs/extracted-engine/`. Every lint script has an analog in `tools/asset-catalog/scripts/`.

---

## File Classification

### `tools/protocol-doc/` — new GML scanner CLI for SDOC-02

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `tools/protocol-doc/package.json` | config | n/a | `tools/asset-catalog/package.json` | exact |
| `tools/protocol-doc/tsconfig.json` | config | n/a | `tools/asset-catalog/tsconfig.json` | exact |
| `tools/protocol-doc/vitest.config.ts` | config | n/a | `tools/asset-catalog/vitest.config.ts` | exact |
| `tools/protocol-doc/cli.ts` | cli (dispatcher) | request-response (argv→exit) | `tools/asset-catalog/cli.ts` | exact |
| `tools/protocol-doc/src/types.ts` | model (type-only) | n/a | `tools/asset-catalog/src/types.ts` | exact (re-export pattern) |
| `tools/protocol-doc/src/scanner/opcode-trace.ts` | service | file-I/O + transform | `tools/asset-catalog/src/load.ts` (file walker) + `tools/asset-catalog/src/grep.ts` (line scanner) | role-match |
| `tools/protocol-doc/src/scanner/citations.ts` | service | pure transform | `tools/asset-catalog/src/derive.ts` (pure transform style) | role-match |
| `tools/protocol-doc/src/derive/xls-hints.ts` | service | file-I/O (CSV/XLS read) | (no in-repo analog — new pattern; closest is `tools/asset-catalog/src/load.ts` JSON reader idioms) | partial |
| `tools/protocol-doc/src/emit/json.ts` | service | file-I/O write | `tools/asset-catalog/src/emit.ts` lines 45-70 (`writeJsonDeterministic`) | exact |
| `tools/protocol-doc/src/emit/markdown.ts` | service | file-I/O write | `tools/asset-catalog/src/emit.ts` lines 243-343 (`emitIndexMd` + `writeMarkdownDeterministic`) | exact |
| `tools/protocol-doc/src/emit/typescript.ts` | service | file-I/O write (string template) | (no exact analog — new pattern; closest is `tools/extract-gmd/src/emit/manifest.ts` for line-array string assembly) | partial |
| `tools/protocol-doc/output/protocol.ts` | data (emitted artifact, committed) | n/a | (no analog — emitted code committed for Phase 4 import; `tools/extract-gmd/data/action-ids.json` is the closest "committed canonical data" analog) | partial |
| `tools/protocol-doc/data/opcode-names.csv` | data (cross-ref input) | n/a | `tools/extract-gmd/data/action-ids.json` | role-match |
| `tools/protocol-doc/tests/unit/*.test.ts` | test (unit) | n/a | `tools/asset-catalog/tests/grep.test.ts`, `derive.test.ts`, `emit.test.ts` | exact |
| `tools/protocol-doc/tests/integration/cli.test.ts` | test (integration, spawns CLI) | n/a | `tools/asset-catalog/tests/integration/cli.test.ts` | exact |

### `tools/save-format-doc/` — new GML scanner CLI for SDOC-03

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `tools/save-format-doc/package.json` | config | n/a | `tools/asset-catalog/package.json` | exact |
| `tools/save-format-doc/tsconfig.json` | config | n/a | `tools/asset-catalog/tsconfig.json` | exact |
| `tools/save-format-doc/vitest.config.ts` | config | n/a | `tools/asset-catalog/vitest.config.ts` | exact |
| `tools/save-format-doc/cli.ts` | cli (dispatcher) | request-response | `tools/asset-catalog/cli.ts` | exact |
| `tools/save-format-doc/src/types.ts` | model | n/a | `tools/asset-catalog/src/types.ts` | exact (re-export pattern) |
| `tools/save-format-doc/src/scanner/format-trace.ts` | service | file-I/O + transform | `tools/protocol-doc/src/scanner/opcode-trace.ts` (sister tool) + `tools/asset-catalog/src/load.ts` | role-match |
| `tools/save-format-doc/src/emit/{json,markdown,typescript}.ts` | service | file-I/O write | `tools/protocol-doc/src/emit/*` (sister tool, identical shape) + `tools/asset-catalog/src/emit.ts` | exact |
| `tools/save-format-doc/output/save-formats.ts` | data (emitted artifact) | n/a | `tools/protocol-doc/output/protocol.ts` (sister) | exact |
| `tools/save-format-doc/tests/**/*.test.ts` | test | n/a | `tools/asset-catalog/tests/**/*` | exact |

### `tools/db-schema/` — first Drizzle schema authoring tool (LOCKED per D-21 + CLAUDE.md hard rule #6: no new TypeScript outside `tools/` before Phase 4. Phase 4 imports/copies the emitted `tables.ts` when wiring the runner.)

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `tools/db-schema/package.json` | config | n/a | `tools/asset-catalog/package.json` | exact (mirror — TS 5.6.3 + tsx + vitest, drizzle-kit + better-sqlite3 added) |
| `tools/db-schema/tsconfig.json` | config | n/a | `tools/asset-catalog/tsconfig.json` | exact |
| `tools/db-schema/vitest.config.ts` | config | n/a | `tools/asset-catalog/vitest.config.ts` | exact |
| `tools/db-schema/drizzle.config.ts` | config (drizzle-kit) | n/a | (no in-repo analog — first drizzle-kit config) | no analog |
| `tools/db-schema/src/tables.ts` | model (Drizzle schema) | n/a (DDL declaration) | `tools/asset-catalog/src/types.ts` (type-only TS file with column-like field decls) | role-match |
| `tools/db-schema/src/index.ts` | model re-export | n/a | `tools/asset-catalog/src/index.ts` | exact |
| `tools/db-schema/migrations/0001_baseline.sql` | data (drizzle-kit emit, source-of-truth) | drizzle-kit generate output | (no in-repo analog — first migration) | no analog |
| `docs/extracted-server/0001_baseline.sql` | data (byte-identical doc copy) | copy from tools/db-schema/migrations/ | n/a (the canonical Phase 3 verification artifact; equality-checked by `git diff --exit-code`) | no analog |
| `tools/db-schema/scripts/check-source-comments.mjs` | script (lint) | analyze tables.ts | `tools/asset-catalog/scripts/lint-docs.mjs` | role-match |
| `tools/db-schema/tests/**/*.test.ts` | test | n/a | `tools/asset-catalog/tests/**/*` | exact |

### `docs/extracted-server/*.md` — subsystem narrative docs

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `docs/extracted-server/README.md` | doc (jump table) | n/a | `docs/extracted-engine/README.md` | exact |
| `docs/extracted-server/account-auth.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/save-load.md` (auth-adjacent + plaintext-creds caveat) + `docs/extracted-engine/client-networking.md` (functional-cluster shape) | role-match |
| `docs/extracted-server/world-simulation.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/scene-room-model.md` | role-match |
| `docs/extracted-server/room-management.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/scene-room-model.md` | exact (mirror) |
| `docs/extracted-server/chat.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/client-networking.md` (call-pattern + AUTOGEN script-roster) | role-match |
| `docs/extracted-server/persistence.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/save-load.md` | exact (mirror) |
| `docs/extracted-server/packet-protocol.md` | doc (narrative companion to autogen JSON) | n/a | `docs/extracted-engine/client-networking.md` | exact (server-side mirror; cross-link target) |
| `docs/extracted-server/admin-anti-port.md` | doc (anti-port reference, REJECTED-AS-PORTED rows) | n/a | `docs/extracted-engine/admin-anti-port.md` | exact |
| `docs/extracted-server/client-server-bridge.md` | doc (cross-link bridge) | n/a | `docs/extracted-engine/README.md` "task-keyed jump table" sections + `docs/extracted-engine/client-networking.md` thin-wrapper-into-wiki pattern | role-match |
| `docs/extracted-server/message-board.md` | doc (subsystem narrative) | n/a | `docs/extracted-engine/save-load.md` (file-format-narrative shape) | role-match |
| `docs/extracted-server/unknown-actions-status.md` | doc (resolution-status table) | n/a | `docs/extracted-engine/unknown-actions-status.md` | exact |
| `docs/extracted-server/SUBSYSTEM-MAP.json` | data (autogen driver) | n/a | `docs/extracted-engine/SUBSYSTEM-MAP.json` | exact |
| `docs/extracted-server/protocol.md` | doc (autogen-rendered) | n/a | `docs/extracted-engine/MATRIX.md` (canonical-JSON → AUTOGEN MD pattern) | exact |
| `docs/extracted-server/protocol.json` | data (canonical) | n/a | `docs/extracted-engine/MATRIX-rows.json` | exact |
| `docs/extracted-server/save-formats.md` | doc (autogen-rendered) | n/a | `docs/extracted-engine/MATRIX.md` | exact |
| `docs/extracted-server/save-formats.json` | data (canonical) | n/a | `docs/extracted-engine/MATRIX-rows.json` | exact |
| `docs/extracted-server/parity-checklist.md` | doc (autogen-rendered) | n/a | `docs/extracted-engine/MATRIX.md` | exact |
| `docs/extracted-server/parity-checklist.json` | data (canonical) | n/a | `docs/extracted-engine/MATRIX-rows.json` | exact |
| `docs/extracted-server/asset-catalog/index.{json,md}` | data + doc (re-invoked tool output) | n/a | `docs/extracted-engine/asset-catalog/index.{json,md}` | exact (same tool, different input dir) |

### ADRs

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `docs/adr/0002-persistence-layer.md` | doc (ADR) | n/a | `docs/adr/0001-client-engine.md` | exact (Michael Nygard format locked by Phase 2) |
| `docs/adr/0003-canonical-snapshot.md` | doc (ADR) | n/a | `docs/adr/0001-client-engine.md` | exact |

### Lint scripts (Phase 3 D-22)

The research locates lints under repo-root `scripts/lint-*.mjs` (per RESEARCH.md §Recommended Project Structure lines 335-341), but the existing Phase 2 lints live at `tools/asset-catalog/scripts/lint-*.mjs`. **Planner decision:** match Phase 2's location (`tools/<tool>/scripts/`) so each tool owns its own lint surface, OR migrate all lints to repo-root `scripts/`. The pattern map covers both readings.

| New File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `tools/protocol-doc/scripts/lint-protocol.mjs` (or `scripts/lint-protocol.mjs`) | script (linter) | file-I/O scan | `tools/asset-catalog/scripts/lint-matrix.mjs` (canonical-JSON validator + cross-check rendered MD) | exact |
| `tools/save-format-doc/scripts/lint-save-formats.mjs` | script (linter) | file-I/O scan | `tools/asset-catalog/scripts/lint-matrix.mjs` | exact |
| `scripts/lint-parity.mjs` (or `tools/protocol-doc/scripts/`) | script (linter) | file-I/O scan | `tools/asset-catalog/scripts/lint-matrix.mjs` + `tools/asset-catalog/scripts/lint-adr.mjs` | exact |
| `scripts/lint-wiki-errata.mjs` | script (linter) | file-I/O scan | `tools/asset-catalog/scripts/lint-docs.mjs` (file-presence/string-presence check) | role-match |
| `scripts/lint-subsystem-mds.mjs` (if added beyond `lint-docs.mjs`) | script (linter) | file-I/O scan | `tools/asset-catalog/scripts/lint-docs.mjs` (drift-vs-regen-output pattern) | exact |
| `scripts/verify-phase-3.mjs` | script (orchestrator) | file-I/O scan + spawn | `tools/asset-catalog/scripts/lint-docs.mjs` (spawnSync pattern) | role-match |

### Wiki/research errata patches (modifications, not new files)

| Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `decomp/wiki/16-bno-bnb-notes.md` | doc (errata patch) | n/a | (in-place edit; existing wiki style; see `decomp/wiki/13-modern-tool-incompat.md` "negative-finding" tone) | exact |
| `.planning/research/PITFALLS.md` | doc (errata patch — A5 wording) | n/a | itself (in-place edit) | exact |
| `package.json` (root) | config (add `catalog:server`, `catalog:all`, `protocol-doc:*`, `save-format-doc:*` scripts) | n/a | `package.json` (existing `catalog:client` lines 10-12 are the template) | exact |

---

## Pattern Assignments

### `tools/protocol-doc/package.json` (config)

**Analog:** `tools/asset-catalog/package.json`

**Full file to copy and edit** (lines 1-23):
```json
{
  "name": "protocol-doc",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Phase 3 39dll opcode reverse-engineering CLI. Reads extracted/server-5-4/, emits docs/extracted-server/protocol.{json,ts,md}.",
  "bin": { "protocol-doc": "./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/",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "5.6.3",
    "tsx": "4.21.0",
    "vitest": "4.1.5",
    "@types/node": "25.6.0"
  }
}
```

**Critical conventions to preserve from asset-catalog:**
- `"type": "module"` (ESM throughout)
- All deps pinned exactly (no `^`/`~`)
- `"private": true`
- **NO production dependencies** unless XLS-via-`xlsx@0.18.5` is chosen (researcher recommends CSV-conversion path — keep zero runtime deps)
- `bin` field uses `.ts` directly (tsx resolves it)

`tools/save-format-doc/package.json` is identical except `name`, `description`, `bin` field swapped.

---

### `tools/protocol-doc/tsconfig.json` (config)

**Analog:** `tools/asset-catalog/tsconfig.json`

**Full file to copy verbatim** (`tools/asset-catalog/tsconfig.json` lines 1-15):
```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"]
}
```

**Conventions to preserve:**
- `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` non-negotiable (catch real bugs in trace-table lookup; mirrors Phase 2 PATTERNS.md note line 110)
- `include` deliberately excludes `scripts/` so `.mjs` lints sit outside strict TS (mirrors Phase 2 IN-09 rationale)

---

### `tools/protocol-doc/vitest.config.ts` (config)

**Analog:** `tools/asset-catalog/vitest.config.ts`

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

export default defineConfig({
  test: {
    pool: 'forks',
    maxWorkers: 1,
    include: ['tests/**/*.test.ts'],
    testTimeout: 30000,
  },
});
```

**Why `maxWorkers: 1`:** Determinism tests write/re-read tmp dirs; parallel workers race on `mkdtempSync` cleanup.

---

### `tools/protocol-doc/cli.ts` (cli, request-response)

**Analog:** `tools/asset-catalog/cli.ts` lines 1-105 (verbatim shape; subcommand names swap)

**Shebang + header pattern** (asset-catalog cli.ts lines 1-12):
```typescript
#!/usr/bin/env node
// tools/protocol-doc/cli.ts
// CLI dispatcher for `protocol-doc`. 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 gml, drift detected)
//   2  usage error (missing/unknown command, missing required args)

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

**Switch dispatcher** (asset-catalog cli.ts lines 25-89): copy structure exactly. Subcommands:
- `catalog <extracted-dir> <docs-out-dir>` → reads `extracted/server-5-4/`, writes `docs/extracted-server/protocol.{json,md}` + `tools/protocol-doc/output/protocol.ts`
- `regen-autogen <docs-dir>` → refreshes AUTOGEN blocks in subsystem MDs
- `verify <docs-dir>` → re-derives + byte-compares
- `help`/`--help`/`-h` → exit 0
- unknown → exit 2

**Cross-platform invoke-direct guard** (asset-catalog cli.ts lines 92-105) — copy verbatim. The `fileURLToPath(import.meta.url) === process.argv[1]` check is portable across POSIX and Windows where `import.meta.url` is `file:///C:/...` while `process.argv[1]` is `C:\...`.

`tools/save-format-doc/cli.ts` is identical except subcommand body invokes `runSaveFormatCatalog` instead of `runProtocolCatalog`.

---

### `tools/protocol-doc/src/types.ts` (model, type-only)

**Analog:** `tools/asset-catalog/src/types.ts`

**Re-export pattern** (asset-catalog types.ts lines 10-34): D-06 per-opcode metadata builds on Phase 1 `Script` shape — re-export to guarantee zero drift:
```typescript
// tools/protocol-doc/src/types.ts
// Source: D-06 (per-opcode metadata schema). Tier 1 (script roster) re-exports
// from extract-gmd verbatim; tier 2 (opcode trace) is protocol-doc-specific.

export type {
  Script,
  GmObject,
  ObjectEvent,
  Settings,
  ProjectFile,
  ResourceBase,
} from '../../extract-gmd/src/types.js';

// Tier 2 — opcode trace shapes per RESEARCH §"Pattern 1: 39dll Opcode
// Extraction Procedure" lines 350-395 + D-06.

export interface OpcodeField {
  name: string;            // semantic name from script context
  gml_type: 'byte' | 'short' | 'ushort' | 'int' | 'uint' | 'float' | 'double' | 'chars' | 'string';
  ts_type: 'number' | 'string';
  byte_size: number;       // -1 for variable-length string
}

export interface GmlOrigin {
  script: string;          // e.g., "0359-server_receive.gml"
  line: number;            // 1-indexed
  snippet: string;         // 1-3 lines of GML for traceability
}

export interface OpcodeRow {
  opcode_byte: number;
  direction: 'c2s' | 's2c';
  mvp: boolean;            // CLI-08 movement+chat slice
  name: string;            // semantic name
  fields: OpcodeField[];
  gml_origin: GmlOrigin[]; // ≥1 required (lint-protocol enforces)
  sample_bytes: string;    // hex test vector
}

export interface ProtocolTable {
  inputManifestSha256: string;
  opcodes: OpcodeRow[];
}
```

**Why relative `../../extract-gmd/src/types.js` import:** NodeNext requires `.js` even from `.ts` source. Phase 4+ pnpm workspaces will replace with `@rebno/extract-gmd`; until then this is the convention (Phase 1 D-17 / Phase 2 D-15 — workspaces deferred to Phase 4 per CLAUDE.md hard rule #6).

`tools/save-format-doc/src/types.ts` mirrors with `FormatGrammar`, `SectionedField`, `FlatField` shapes per D-10.

---

### `tools/protocol-doc/src/scanner/opcode-trace.ts` (service, file-I/O + transform)

**Analog:** `tools/asset-catalog/src/load.ts` lines 1-40 (file walker + defensive missing-dir handling) + `tools/asset-catalog/src/grep.ts` (line scanner / `wordBoundaryMatch`)

**File walker pattern** (load.ts lines 19-34):
```typescript
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

// Path conventions (verified against extracted/server-5-4/ on 2026-05-03):
//   scripts/<NN>-<name>.gml                    (flat .gml files)
//   objects/<NN>-<name>/{meta.json, events/<EventName>[-<N>].{gml,dnd.json}}
```

**Sorted enumeration** (load.ts pattern + emit.ts byId comparator):
```typescript
// readdirSync(...).sort() for D-15/D-16 determinism
for (const entry of readdirSync(scriptsDir).sort()) {
  if (!entry.endsWith('.gml')) continue;
  // ... line-by-line scan
}
```

**Algorithm to implement** (per RESEARCH.md lines 357-395 — "Pattern 1: 39dll Opcode Extraction Procedure"):
1. Read each `.gml` file line-by-line.
2. When `sendmessage(...)` is seen, walk backward through the same function up to the most recent `clearbuffer()` call.
3. The first `writebyte` after `clearbuffer` is the opcode.
4. Subsequent `writeX` calls in source order are the fields.
5. Direction inferred from script context (server_receive.gml case branches → c2s; arbitrary outbound construct → s2c).

**Branch-coverage edge case** (RESEARCH lines 396-397): `0359-server_receive.gml` case 5 (login) writes a discriminated union keyed on first-int sign. Scanner emits one `OpcodeTrace` per code path through the handler.

**Citation building** mirrors `tools/asset-catalog/src/grep.ts` `wordBoundaryMatch` line-scanning pattern.

---

### `tools/save-format-doc/src/scanner/format-trace.ts` (service)

**Analog:** `tools/protocol-doc/src/scanner/opcode-trace.ts` (sister tool, same shape)

**Algorithm differs only at the call-pattern level** (per RESEARCH.md §"Pattern 2: .bno/.bnb/.bnu Save-Format Extraction Procedure"):
- Trace `file_text_open_read` / `file_text_open_write` calls
- Walk forward through `file_text_read_string` / `file_text_readln` / `file_text_write_string` / `file_text_writeln` until `file_text_close`
- Detect section markers (`@TOPIC`, `@REPLY`) in `0365-mb_backup.gml` for `sectioned` grammar type per D-10

**Determinism + sorted enumeration** identical to `opcode-trace.ts`.

---

### `tools/protocol-doc/src/emit/json.ts` (service, file-I/O write)

**Analog:** `tools/asset-catalog/src/emit.ts` lines 39-70 — copy verbatim

**Pattern to copy** (emit.ts lines 45-56):
```typescript
/** Mirror of tools/extract-gmd/src/emit/json.ts:14-16. */
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)
  writeFileSync(path, json.replace(/\r\n/g, '\n'), { encoding: 'utf8' });
}
```

**`sortKeysRecursive` helper** (emit.ts lines 58-70) — copy verbatim including the `Buffer.isBuffer(v)` guard (preserves byte-array determinism without recursing into numeric indices).

**Determinism contract:** sorted keys, 2-space indent, single trailing LF, LF-only line endings (CRLF stripped). Phase 1 D-15 / Phase 2 D-16. Enforced by `lint-protocol.mjs` round-trip check.

---

### `tools/protocol-doc/src/emit/markdown.ts` (service, file-I/O write)

**Analog:** `tools/asset-catalog/src/emit.ts` lines 226-343 (`emitIndexMd` + `writeMarkdownDeterministic`)

**Line-array assembly pattern** (emit.ts lines 244-333):
```typescript
function emitProtocolMd(table: ProtocolTable): string {
  const lines: string[] = [];
  lines.push('# 39dll Wire Protocol — extracted/server-5-4/');
  lines.push('');
  lines.push('Auto-generated by `tools/protocol-doc`. Do not hand-edit. Re-run with `pnpm protocol-doc:catalog`.');
  lines.push('');
  // ... summary, c2s table, s2c table, per-opcode field tables
  return lines.join('\n');
}
```

**Pipe-escaping helper** (emit.ts lines 239-241) — copy verbatim:
```typescript
function md(cell: string | number): string {
  return typeof cell === 'number' ? String(cell) : cell.replace(/\|/g, '\\|');
}
```

**LF-only writer** (emit.ts lines 337-343) — copy verbatim:
```typescript
function writeMarkdownDeterministic(path: string, content: string): void {
  const ending = content.endsWith('\n') ? '' : '\n';
  writeFileSync(path, (content + ending).replace(/\r\n/g, '\n'), { encoding: 'utf8' });
}
```

**Critical:** zero `Date.now()` / `new Date()` calls (lint-protocol.mjs catches drift; mirrors emit.ts line 11-13 comment).

---

### `tools/protocol-doc/src/emit/typescript.ts` (service, file-I/O write)

**Analog:** No exact analog — closest is `tools/extract-gmd/src/emit/manifest.ts` for the line-array string-assembly + LF normalization pattern.

**Pattern:** Build the TS source as a `lines.push(...)` array, join with `\n`, write through `writeMarkdownDeterministic`-equivalent:
```typescript
// tools/protocol-doc/src/emit/typescript.ts
// Source: D-05 — emit tools/protocol-doc/output/protocol.ts as TypeScript
// types + binary codec stubs. Phase 4 packages/protocol/ imports from here.

export function emitProtocolTs(table: ProtocolTable): string {
  const lines: string[] = [];
  lines.push('// AUTO-GENERATED by tools/protocol-doc. Do not hand-edit.');
  lines.push('// Source: docs/extracted-server/protocol.json');
  lines.push('// Re-run: pnpm protocol-doc:catalog');
  lines.push('');
  lines.push('export const PROTOCOL_VERSION = "0.1.0";');
  lines.push('');
  // ... per-opcode types + codec stubs
  return lines.join('\n');
}
```

**Output location:** `tools/protocol-doc/output/protocol.ts` (per D-05 — committed artifact, NOT yet a workspace package per CLAUDE.md hard rule #6).

`tools/save-format-doc/src/emit/typescript.ts` mirrors with `save-formats.ts` output.

---

### `tools/protocol-doc/src/emit/index.ts` (orchestrator) — analog `tools/asset-catalog/src/emit.ts` `runCatalog/runRegenAutogen/runVerify`

**Analog:** `tools/asset-catalog/src/emit.ts` lines 351-477 — copy `runCatalog` / `runRegenAutogen` / `runVerify` shape verbatim.

**`runCatalog` skeleton** (emit.ts lines 351-378):
```typescript
export async function runCatalog(extractedDir: string, outDir: string): Promise<void> {
  if (!existsSync(extractedDir)) {
    throw new Error(`extracted dir not found: ${extractedDir}`);
  }
  mkdirSync(outDir, { recursive: true });

  const traces = scanGmlForOpcodes(extractedDir);  // protocol-doc/src/scanner
  const table = buildProtocolTable(traces);

  writeJsonDeterministic(join(outDir, 'protocol.json'), table);
  writeMarkdownDeterministic(join(outDir, 'protocol.md'), emitProtocolMd(table));
  writeFileSync(join(toolDir, 'output', 'protocol.ts'), emitProtocolTs(table));

  // Refresh AUTOGEN blocks in subsystem MDs (D-11 hand-authored + autogen pattern)
  try { await runRegenAutogen(outDir); } catch (e) {
    process.stderr.write(`autogen refresh skipped: ${(e as Error).message}\n`);
  }
}
```

**`runVerify` drift-detect pattern** (emit.ts lines 441-477) — copy verbatim including the three-candidate `extracted/server-5-4` resolution (CWD-relative, docsDir-sibling, tool-relative-via-`import.meta.url`). The third candidate is critical for tmp-dir integration tests where docsDir is a copy.

---

### `tools/protocol-doc/output/protocol.ts` (data, emitted artifact)

**Analog:** No exact analog. Closest "committed canonical generated artifact" is `extracted/client-5-8/MANIFEST.sha256`. Closest "committed canonical data" is `tools/extract-gmd/data/action-ids.json`.

**Header convention** (mirror `tools/asset-catalog/src/emit.ts` line 11-13 determinism comment):
```typescript
// AUTO-GENERATED by tools/protocol-doc. Do not hand-edit.
// Source: docs/extracted-server/protocol.json @ <inputManifestSha256 line 1>
// Re-run: pnpm protocol-doc:catalog
//
// Phase 4 packages/protocol/ imports from here. Do not delete.
```

**Why committed:** Phase 4 SRV-01..03 imports this directly. Per Phase 1 D-17 / Phase 2 D-15, no pnpm workspaces until Phase 4 — so the artifact ships as a source-controlled file, not as a package export.

`tools/save-format-doc/output/save-formats.ts` follows the same pattern.

---

### `tools/protocol-doc/data/opcode-names.csv` (data, cross-ref input)

**Analog:** `tools/extract-gmd/data/action-ids.json`

**Purpose** (per D-07): Pre-converted CSV of `legacy/open-source-release/BN Online Message ID's Table.xls`. Researcher recommends CSV-conversion path over `xlsx@0.18.5` dep (RESEARCH lines 130-134, 152-159).

**Conversion procedure** (one-time, manual, committed result):
1. Open XLS in LibreOffice or Excel
2. Export as UTF-8 CSV with LF line endings
3. Commit alongside this PATTERN comment in CSV header

**Convention:** XLS hints provide legacy opcode names; per D-07 extracted GML wins on conflict. Lint enforces every opcode in `protocol.json` has a `name` field even if no XLS hint matches.

---

### `tools/protocol-doc/tests/integration/cli.test.ts` (test, integration)

**Analog:** `tools/asset-catalog/tests/integration/cli.test.ts` lines 1-60 — copy verbatim, swap binary name

**Pattern to copy** (asset-catalog cli.test.ts lines 18-51):
```typescript
const repoRoot = resolve(fileURLToPath(new URL('../../../..', import.meta.url)));
const toolDir = join(repoRoot, 'tools', 'protocol-doc');
const realDataPath = join(repoRoot, 'extracted', 'server-5-4');
const realDataExists = existsSync(realDataPath);

function runCli(args: string[]): CliResult {
  const isWindows = process.platform === 'win32';
  const res = spawnSync('pnpm', ['exec', 'tsx', 'cli.ts', ...args], {
    cwd: toolDir,
    encoding: 'utf-8',
    shell: isWindows,
  });
  return { code: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
}
```

**Critical Windows note** (asset-catalog cli.test.ts lines 36-45): `cwd: toolDir`, NOT `cwd: repoRoot` — pnpm tsx is per-tool, not workspace-level. `shell: isWindows` is required for `pnpm` resolution on Windows. This is a load-bearing convention.

**Exit-code matrix to test** (asset-catalog cli.test.ts lines 53-60+):
- CLI-INT-01: no command → exit 2 with "Usage:" on stderr
- CLI-INT-02: unknown command → exit 2
- CLI-INT-03: catalog with missing dir → exit 1
- CLI-INT-04: catalog success → exit 0, JSON written
- CLI-INT-05: verify without prior catalog → exit 1
- CLI-INT-06: verify after catalog → exit 0
- CLI-INT-07: verify after manual edit → exit 1 (drift detected)

---

### `docs/extracted-server/tables.ts` (model, Drizzle schema)

**Analog:** No in-repo analog — first DB code in project. Closest "type-only field-decl TS file" is `tools/asset-catalog/src/types.ts`.

**Authoritative source:** Drizzle 0.45.2 docs (`orm.drizzle.team`) — referenced from RESEARCH.md §Standard Stack lines 144-145.

**Skeleton pattern** (per Drizzle docs + D-13):
```typescript
// docs/extracted-server/tables.ts
// Source: SDOC-04 (D-13). Drizzle table definitions for Phase 4 SRV-01..03.
// Phase 4 imports from here verbatim — no redesign loop.
//
// Each column comment cites the originating .bnu/.bnb field per the save-formats
// grammar in docs/extracted-server/save-formats.json. Per D-14, this schema is
// NOT a port of the legacy text shape — it is normalized SQL designed for the
// new server. Cite, don't preserve.

import { sqliteTable, text, integer, blob } from 'drizzle-orm/sqlite-core';

export const accounts = sqliteTable('accounts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  username: text('username').notNull().unique(),
  passwordHash: text('password_hash').notNull(), // argon2id, ~95 bytes
  createdAt: integer('created_at').notNull(),
  lastLoginAt: integer('last_login_at'),
  forceReset: integer('force_reset', { mode: 'boolean' }).notNull().default(false),
  role: text('role').notNull().default('player'), // 'player' | 'mod' | 'admin'
});

export const legacyCredentialsStaging = sqliteTable('legacy_credentials_staging', {
  username: text('username').primaryKey(),
  legacyHash: blob('legacy_hash'),
  algorithm: text('algorithm').notNull(), // 'plaintext' | 'bcrypt-weak' | ...
  forceReset: integer('force_reset', { mode: 'boolean' }).notNull(),
  legacySource: text('legacy_source').notNull(), // 'enlyzeam-current/localList.txt:LINE'
  importedAt: integer('imported_at').notNull(),
});

// ... characters, inventory_items, message_board_topics, message_board_replies,
// audit_log, sessions per D-13.
```

**Conventions to apply** (D-13 + D-14):
- Every column carries a comment citing the originating .bnu/.bnb field (or D-04 for staging).
- `legacy_credentials_staging` is read-once-then-purged per D-04.
- `audit_log` is seeded empty for Phase 7 PAR-07 (avoids Phase 7 schema migration).
- Original `.bnu` text format is read-once-then-discarded by SRV-10 — no field-name preservation.

---

### `docs/extracted-server/schema.sql` (data, DDL)

**Analog:** No in-repo analog. Generated by `drizzle-kit generate` against `tables.ts`.

**Convention:** Output of `pnpm drizzle-kit generate` against `tables.ts`. Committed verbatim. Per D-13 this is the consumed contract for Phase 4 SRV-01..03.

---

### `docs/extracted-server/0001_baseline.sql` (data, drizzle-kit migration)

**Analog:** No in-repo analog.

**Convention:** Output of `drizzle-kit generate` migration step. Numbered `0001_` per drizzle-kit naming. Per Phase 3 Out-of-Scope (D-14 "Drizzle migration runner wiring"), Phase 3 produces this artifact but does NOT run it — Phase 4 owns the runner.

---

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

**Analog:** `docs/extracted-engine/README.md`

**Pattern:** Task-keyed jump table per Phase 2 D-09 / D-15. Format: H2 sections per consumer task (e.g., "If you are implementing CLI-08 movement..."), each with bullet links into specific subsystem MDs. Phase 2's README is the template; mirror its structure swapping client→server subsystem names.

---

### `docs/extracted-server/account-auth.md` (doc, subsystem narrative)

**Analog:** `docs/extracted-engine/save-load.md` (auth/credential adjacency) + `docs/extracted-engine/client-networking.md` (functional-cluster shape with AUTOGEN script-roster)

**Front-matter pattern** (`docs/extracted-engine/client-networking.md` lines 1-4):
```markdown
---
mvp: yes
subsystem: account-auth
---

# Account & Authentication
```

**Functional-cluster body shape** (client-networking.md lines 6-55):
- One paragraph per behavior cluster (login, logout, account creation, password rehash, force-reset)
- 5-30 line GML snippets quoted inline with `script:line` citations
- AUTOGEN script-roster table at the bottom (mirror lines 56-80 — `<!-- AUTOGEN:scripts:start -->` block)

**Critical content (per D-04):** `legacy_credentials_staging` table is documented here as the read-once-then-purge migration path. Plaintext rows from `localList.txt` NEVER copied verbatim into `accounts`. Phase 5 RESTORE.md updates included as a forward-link.

**mvp tag:** `yes` — login is on the CLI-08 MVP path.

---

### `docs/extracted-server/world-simulation.md`, `room-management.md`, `chat.md`, `persistence.md`, `message-board.md`, `client-server-bridge.md` (docs, subsystem narratives)

**Analog:** Each maps directly to a Phase 2 doc as table above. **Content shape is the SAME for all** — front-matter + functional clusters + AUTOGEN script-roster.

**Cross-link discipline (D-15 / D-16):**
- `client-server-bridge.md` cross-links `docs/extracted-engine/client-networking.md` (do NOT duplicate — link).
- `packet-protocol.md` cross-links `docs/extracted-server/protocol.{json,md}` (autogen) and `docs/extracted-engine/client-networking.md` (client side).
- All wiki references go through `decomp/wiki/*.md` — never duplicate wiki content (Phase 1 D-19 thin-wrapper rule).

---

### `docs/extracted-server/packet-protocol.md` (doc, narrative companion to autogen JSON)

**Analog:** `docs/extracted-engine/MATRIX.md` lines 1-30 (narrative + AUTOGEN-rendered tables) + `docs/extracted-engine/client-networking.md` (cross-link target)

**Pattern (per D-05):** Narrative companion to `protocol.json`. Hand-authored prose explains the call-pattern + extraction methodology + sample frames; the per-opcode tables are AUTOGEN blocks rendered from `protocol.json`.

**AUTOGEN block names to reserve:**
- `<!-- AUTOGEN:opcodes-c2s:start --> ... :end -->`
- `<!-- AUTOGEN:opcodes-s2c:start --> ... :end -->`
- `<!-- AUTOGEN:mvp-opcodes:start --> ... :end -->` (filtered for CLI-08)

Generators added to `tools/protocol-doc/src/autogen.ts` (mirror `tools/asset-catalog/src/autogen.ts`).

---

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

**Analog:** `docs/extracted-engine/admin-anti-port.md` (lines 1-50 verbatim shape)

**Front-matter** (admin-anti-port.md lines 1-5):
```markdown
---
mvp: no
subsystem: admin
status: anti-port-reference
---

# Admin Anti-Port Reference
```

**Top-warning block** (admin-anti-port.md line 9):
```markdown
> **WARNING — DO NOT PORT.** This document catalogues the original BNO admin model AS A FORCING FUNCTION, not as a specification. Per [CLAUDE.md hard rule #3](../../CLAUDE.md), the original "Ctrl+E run clipboard as superuser" admin pattern is a **remote code execution vulnerability in shipped product form** and must NEVER appear in the rebuild.
```

**Per-command table format** (admin-anti-port.md lines 22-30):
```markdown
| Command | Original behaviour | REJECTED-AS-PORTED reason | Rebuild equivalent |
|---------|-------------------|--------------------------|-------------------|
| **Ctrl+E** | Execute whatever GML code is currently on the OS clipboard... | **REJECTED-AS-PORTED** — RCE-as-a-feature. Trivially weaponisable... | Phase 7 PAR-07: separate authenticated admin web UI... |
```

**Phase 3 extension (per D-20):** Each REJECTED row adds a fifth column or a per-row TS intent shape block:
```typescript
// Modernized replacement for ,ServerCommands.txt 'kick'
interface AdminKickIntent {
  command: 'kick';
  payload: {
    targetAccountId: string;
    reason: string;
    durationSec?: number;
  };
}
```

**Source files to catalog (per D-20):**
- `legacy/open-source-release/,ServerCommands.txt`
- `legacy/servers/enlyzeam-current/Ctrl+O Codes.txt`
- `,ServerCommands.txt` already cataloged client-side; server-side adds `Account Updater.exe` + `Server Saver.exe` behavior references.

**Modernized commands locked at end of Phase 3 (per D-20):** `kick`, `mute`, `ban`, `assign-role`, `view-audit-log`, `mb-moderate`, `account-recover`. Phase 7 PAR-07 implements; Phase 4 SRV-12 stubs in `apps/server/`.

---

### `docs/extracted-server/unknown-actions-status.md` (doc, resolution-status)

**Analog:** `docs/extracted-engine/unknown-actions-status.md` (exact mirror)

**Source:** `extracted/server-5-4/UNKNOWN-ACTIONS.md` (Phase 1 output, server-side). Phase 3 forcing-function parallel to Phase 2 D-08.

**Convention:** Resolve every entry before phase exit (parallel to Phase 2 verification gate). Status table format mirrors Phase 2 doc verbatim.

---

### `docs/extracted-server/SUBSYSTEM-MAP.json` (data, autogen driver)

**Analog:** `docs/extracted-engine/SUBSYSTEM-MAP.json` (exact format)

**Schema** (per `tools/asset-catalog/src/autogen.ts` lines 128-134):
```typescript
interface SubsystemMap {
  [subsystem: string]: {
    scripts: number[];        // script IDs
    objects: number[];        // object IDs
    'gml-functions': string[]; // function names for grep table
  };
}
```

**Subsystem keys** must match the front-matter `subsystem:` field of the corresponding MD (per `findSubsystemMds` lines 414-431 of autogen.ts). Locked subsystems per D-15: `account-auth`, `world-simulation`, `room-management`, `chat`, `persistence`, `packet-protocol`, `admin`, `client-server-bridge`, `message-board`.

---

### `docs/extracted-server/protocol.json`, `save-formats.json`, `parity-checklist.json` (data, canonical)

**Analog:** `docs/extracted-engine/MATRIX-rows.json` (canonical-data + autogen-render-from-it pattern)

**Convention (per D-05 / D-09 / D-18):** Canonical machine-readable JSON. Sorted keys, 2-space indent, single trailing LF, LF-only. Written by `writeJsonDeterministic`. The `.md` companion's AUTOGEN blocks are rendered from this JSON; lint scripts re-compute and byte-compare.

---

### `docs/extracted-server/protocol.md`, `save-formats.md`, `parity-checklist.md` (doc, autogen-rendered)

**Analog:** `docs/extracted-engine/MATRIX.md` (lines 1-30 + AUTOGEN-block rendering pattern)

**AUTOGEN-block engine:** `tools/protocol-doc/src/autogen.ts` mirrors `tools/asset-catalog/src/autogen.ts` lines 50-103 (`scanAutogenBlocks`) + lines 352-403 (`rewriteAutogenBlocks`).

**Marker grammar** (autogen.ts lines 46-48) — preserve regex:
```typescript
const AUTOGEN_START_RE = /^<!--\s*AUTOGEN:([A-Za-z0-9_-]+):start(\s+[^-]*?)?\s*-->\s*$/;
const AUTOGEN_END_RE = /^<!--\s*AUTOGEN:([A-Za-z0-9_-]+):end\s*-->\s*$/;
```

**Per-NAME generators** (autogen.ts lines 197-346) — copy registry pattern:
```typescript
export const AUTOGEN_GENERATORS: Record<string, (subsystem: string, ctx: AutogenContext) => string> = {
  'opcodes-c2s': (_sub, ctx) => { /* render c2s table from protocol.json */ },
  'opcodes-s2c': (_sub, ctx) => { /* render s2c table from protocol.json */ },
  'mvp-opcodes': (_sub, ctx) => { /* render filtered table */ },
  'save-formats': (_sub, ctx) => { /* render save-formats table */ },
  'parity-checklist': (_sub, ctx) => { /* render parity rows */ },
  'parity-counts': (_sub, ctx) => { /* render disposition counts */ },
};
```

**Reverse-order rewrite** (autogen.ts lines 369-394) — copy verbatim. Apply replacements in reverse start-line order so earlier line indices remain valid as we splice.

---

### `docs/extracted-server/asset-catalog/index.{json,md}` (data + doc, re-invoked tool output)

**Analog:** `docs/extracted-engine/asset-catalog/index.{json,md}` — same `tools/asset-catalog`, different input dir

**Implementation:** No new code. Add `pnpm catalog:server` script to root `package.json` invoking `tools/asset-catalog` against `extracted/server-5-4/` per D-17.

**Repo-level script** (root package.json line 10 template):
```json
"catalog:server": "cd tools/asset-catalog && pnpm exec tsx cli.ts catalog ../../extracted/server-5-4 ../../docs/extracted-server",
"catalog:all": "pnpm run catalog:client && pnpm run catalog:server"
```

**Note:** existing `catalog:all` (root package.json line 11) currently aliases to `catalog:client` only — extend to run both per D-17.

---

### `docs/adr/0002-persistence-layer.md`, `docs/adr/0003-canonical-snapshot.md` (doc, ADRs)

**Analog:** `docs/adr/0001-client-engine.md` (Michael Nygard format locked by Phase 2)

**Header pattern** (0001-client-engine.md lines 1-5):
```markdown
# ADR 0002: Persistence layer

**Date:** 2026-05-03
**Phase:** 03 close
```

**Required sections** (per `lint-adr.mjs` lines 50-55):
```markdown
## Status

**Accepted** — locked at end of Phase 3 (SDOC-04). Re-evaluation gate: Phase 7 retro if Postgres-requiring relational join surfaces.

## Context

[Phase 1 + Phase 3 produced data shape; SQLite + Litestream stack from STACK.md]

## Decision

We will use **better-sqlite3 12.9.0 + Drizzle 0.45.2 + Litestream 0.3.13** for Phase 4+ persistence.

## Consequences

[Phase 4 SRV-01..03 commits to Drizzle patterns; OPS-03 (v2) preserves Postgres migration option; Phase 5 RESTORE.md writes the Litestream restore runbook]

## References

- `docs/extracted-server/save-formats.json` (data shape that drove the lock)
- `docs/extracted-server/tables.ts` (Drizzle schema)
- `.planning/research/STACK.md` §"Persistence: SQLite + Litestream"
- `CLAUDE.md` Tech Stack section
```

**Critical: lint-adr.mjs requires `## Status`, `## Context`, `## Decision`, `## Consequences`** (lines 50-62). Phase 3 ADRs do NOT need MX-* citations (those are Phase 2 MATRIX-specific) — but lint-adr.mjs as-shipped requires ≥3 unique MX-* IDs (line 80-86) which would fail Phase 3 ADRs. **Planner action required:** either (a) generalize `lint-adr.mjs` to accept a `--matrix-optional` flag or per-ADR override, or (b) author a new `lint-adr-phase3.mjs`. Recommended: extend `lint-adr.mjs` with a `--no-matrix` flag (smaller diff, less drift).

**ADR 0003 specifics** (per D-01..D-04):
- Picks `enlyzeam-current` whole; rejects per-record merge.
- Per-snapshot disposition table (enlyzeam-current = canonical; enlyzeam-archive = rejected, reason; local-current = rejected, reason).
- Documents legacy-credentials-staging pipeline (D-04).

---

### `tools/protocol-doc/scripts/lint-protocol.mjs` (or `scripts/lint-protocol.mjs`)

**Analog:** `tools/asset-catalog/scripts/lint-matrix.mjs` lines 1-188 (canonical-JSON validator + cross-check rendered MD totals)

**Header convention** (lint-matrix.mjs lines 1-10):
```javascript
#!/usr/bin/env node
// scripts/lint-protocol.mjs (or tools/protocol-doc/scripts/lint-protocol.mjs)
// Source: Phase 3 D-22.
//
// Validates docs/extracted-server/protocol.json schema + verifies that the
// rendered AUTOGEN blocks in protocol.md (and packet-protocol.md) match the
// JSON-computed tables.
//
// Usage: node scripts/lint-protocol.mjs <docs-dir>
// Exit codes: 0 success, 1 schema/totals failure, 2 usage error.
```

**Argv parsing pattern** (lint-matrix.mjs lines 22-30) — copy verbatim:
```javascript
const arg = process.argv[2];
if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); }
if (!arg) { printUsage(); process.exit(2); }
```

**JSON load + array check** (lint-matrix.mjs lines 32-49) — copy verbatim shape, swap path:
```javascript
const protoPath = join(arg, 'protocol.json');
if (!existsSync(protoPath)) {
  process.stderr.write(`protocol.json not found at ${protoPath}\n`);
  process.exit(1);
}
let table;
try {
  table = JSON.parse(readFileSync(protoPath, 'utf-8'));
} catch (e) {
  process.stderr.write(`protocol.json: invalid JSON: ${e.message}\n`);
  process.exit(1);
}
```

**Per-row schema validation loop** (lint-matrix.mjs lines 56-126) — adapt to OpcodeRow:
- `opcode_byte` is integer 0..255
- `direction` ∈ {`c2s`, `s2c`}
- `mvp` is boolean
- `name` is non-empty string
- `gml_origin` is non-empty array of `{script, line, snippet}` (D-22 enforces ≥1)
- `fields[].byte_size` is integer or -1 (variable string)
- Every `mvp:true` opcode appears in CLI-08 message-list (movement, chat, login, room-join, room-leave, heartbeat) — per D-22

**Cross-check rendered MD** (lint-matrix.mjs lines 137-178) — adapt:
- Read `<docs-dir>/protocol.md`
- Match the AUTOGEN:opcodes-c2s, AUTOGEN:opcodes-s2c, AUTOGEN:mvp-opcodes blocks
- Re-compute expected content from `protocol.json`; byte-compare; fail with "re-run pnpm protocol-doc:catalog" hint on drift

`lint-save-formats.mjs` mirrors with FormatGrammar schema + `load_script` + `save_script` non-empty check (D-22).

---

### `scripts/lint-parity.mjs`

**Analog:** `tools/asset-catalog/scripts/lint-matrix.mjs` (schema validator) + `tools/asset-catalog/scripts/lint-adr.mjs` (cite-resolution check)

**D-22 lint contract:**
- Every parity-checklist row has non-empty `originating_gml`
- Every row has non-null `disposition` ∈ {`in-phase-6`, `in-phase-7`, `deferred-stage-8`, `rejected-with-reason`}
- Every `disposition: 'rejected-with-reason'` has non-empty `reason` field
- Every `originating_opcodes[]` entry resolves to an `opcode_byte` in `protocol.json` (cross-resolution per lint-adr.mjs lines 88-113)
- Aggregate count of rows in each disposition logged to stdout (D-22 disposition counter)

**Aggregate count pattern** (new — extends lint-matrix.mjs lines 153-157 reduce pattern):
```javascript
const dispositions = ['in-phase-6', 'in-phase-7', 'deferred-stage-8', 'rejected-with-reason'];
const counts = Object.fromEntries(dispositions.map(d => [d, 0]));
for (const row of rows) counts[row.disposition]++;
process.stdout.write(`Parity disposition counts: ${JSON.stringify(counts)}\n`);
```

---

### `scripts/lint-wiki-errata.mjs`

**Analog:** `tools/asset-catalog/scripts/lint-docs.mjs` lines 36-52 (file-presence + content-presence check pattern)

**Purpose (per D-08):** Prevent regression of the wiki/16 + PITFALLS A5 errata. Asserts:
1. `decomp/wiki/16-bno-bnb-notes.md` contains the string `file_text_*` (NOT `file_bin_*` as primary claim)
2. `.planning/research/PITFALLS.md` §A5 wording uses `file_text_*`
3. Both files have a marker line (e.g., `<!-- ERRATA-2026-05-03: file_bin_* → file_text_* -->`) so future authors see the correction history

**Implementation skeleton** (mirror lint-docs.mjs lines 36-52):
```javascript
import { existsSync, readFileSync } from 'node:fs';

const WIKI = 'decomp/wiki/16-bno-bnb-notes.md';
const PITFALLS = '.planning/research/PITFALLS.md';

let errors = 0;
if (!existsSync(WIKI)) { /* error */ errors++; }
const wiki = readFileSync(WIKI, 'utf-8');
if (!/file_text_\*/.test(wiki)) {
  process.stderr.write(`${WIKI} missing file_text_* errata (Phase 3 D-08)\n`);
  errors++;
}
if (/^[^\n]*file_bin_\*[^\n]*$(?!.*file_text_\*)/m.test(wiki)) {
  // file_bin_* still present without companion file_text_* note → fail
}
// repeat for PITFALLS A5

process.exit(errors > 0 ? 1 : 0);
```

---

### `scripts/lint-subsystem-mds.mjs`

**Analog:** `tools/asset-catalog/scripts/lint-docs.mjs` (lines 1-153, the canonical drift-vs-regen-output pattern)

**Implementation:** copy `lint-docs.mjs` verbatim, retarget at `docs/extracted-server/` instead of `docs/extracted-engine/`. The tmp-dir copy + spawn `pnpm exec tsx cli.ts regen-autogen` + byte-compare flow is exactly the same.

**Note:** if Phase 3 keeps the existing `tools/asset-catalog/scripts/lint-docs.mjs` and just calls it twice (once per docs tree), no new file is needed. Recommended: parameterize `lint-docs.mjs` to accept `--tool=asset-catalog|protocol-doc|save-format-doc` so one binary regens whichever tool's autogen blocks.

---

### `scripts/verify-phase-3.mjs`

**Analog:** `tools/asset-catalog/scripts/lint-docs.mjs` (spawnSync + sequential-task pattern)

**Purpose:** Phase 3 exit gate — orchestrates all Phase 3 lints in sequence, fails on first non-zero exit:
1. `lint-wiki-errata.mjs`
2. `lint-protocol.mjs docs/extracted-server`
3. `lint-save-formats.mjs docs/extracted-server`
4. `lint-parity.mjs docs/extracted-server`
5. `lint-subsystem-mds.mjs docs/extracted-server`
6. `lint-adr.mjs docs/adr/0002-persistence-layer.md` (with `--no-matrix` flag per planner extension above)
7. `lint-adr.mjs docs/adr/0003-canonical-snapshot.md` (same)
8. `tools/asset-catalog/cli.ts verify docs/extracted-server` (re-run server-side asset-catalog drift check)
9. `tools/protocol-doc/cli.ts verify docs/extracted-server`
10. `tools/save-format-doc/cli.ts verify docs/extracted-server`

**Pattern** (mirror lint-docs.mjs lines 95-110 spawnSync invocation):
```javascript
const isWindows = process.platform === 'win32';
const steps = [
  ['node', ['scripts/lint-wiki-errata.mjs']],
  ['node', ['scripts/lint-protocol.mjs', 'docs/extracted-server']],
  // ...
];
for (const [cmd, args] of steps) {
  const r = spawnSync(cmd, args, { encoding: 'utf-8', shell: isWindows, stdio: 'inherit' });
  if (r.status !== 0) {
    process.stderr.write(`verify-phase-3: ${cmd} ${args.join(' ')} failed (exit ${r.status})\n`);
    process.exit(1);
  }
}
process.exit(0);
```

---

### `decomp/wiki/16-bno-bnb-notes.md` (errata patch — modification)

**Analog:** `decomp/wiki/13-modern-tool-incompat.md` (negative-finding tone) + the file itself

**Edit shape (per D-08):**
1. Replace every `file_bin_*` primary claim with `file_text_*`
2. Add an errata callout at the top:
```markdown
> **ERRATA 2026-05-03 (Phase 3 SDOC-03 / D-08):** Earlier revisions of this
> document asserted `.bno`/`.bnb`/`.bnu` use `file_bin_*` GML primitives.
> Verified ground truth in `extracted/server-5-4/scripts/0365-mb_backup.gml`
> and `0367-users_restore.gml` — these formats use `file_text_*` (line-based,
> with `@TOPIC`/`@REPLY` section markers in `.bnb`). The 39dll `file_bin_*`
> paradigm does not match shipped behavior.
```
3. Update every code example from `file_bin_open_read` → `file_text_open_read`, `file_bin_read_string` → `file_text_read_string`, etc.

**Lint enforcement:** `scripts/lint-wiki-errata.mjs` regression-checks both content presence and the errata callout.

---

### `.planning/research/PITFALLS.md` (errata patch — A5 wording modification)

**Analog:** itself (in-place edit)

**Edit shape (per D-08):** A5 wording correction from `file_bin_*` to `file_text_*`. Same callout pattern as wiki/16. Lint-wiki-errata.mjs covers both files.

---

### `package.json` (root, modification)

**Analog:** Existing root `package.json` lines 5-13 (the existing `catalog:client` script is the template)

**Scripts to add (per D-17 + D-21):**
```json
"catalog:server": "cd tools/asset-catalog && pnpm exec tsx cli.ts catalog ../../extracted/server-5-4 ../../docs/extracted-server",
"catalog:all": "pnpm run catalog:client && pnpm run catalog:server",
"catalog:verify:server": "cd tools/asset-catalog && pnpm exec tsx cli.ts verify ../../docs/extracted-server",
"protocol-doc:catalog": "cd tools/protocol-doc && pnpm exec tsx cli.ts catalog ../../extracted/server-5-4 ../../docs/extracted-server",
"protocol-doc:verify": "cd tools/protocol-doc && pnpm exec tsx cli.ts verify ../../docs/extracted-server",
"save-format-doc:catalog": "cd tools/save-format-doc && pnpm exec tsx cli.ts catalog ../../extracted/server-5-4 ../../docs/extracted-server",
"save-format-doc:verify": "cd tools/save-format-doc && pnpm exec tsx cli.ts verify ../../docs/extracted-server",
"verify:phase-3": "node scripts/verify-phase-3.mjs"
```

**Critical convention** (matches existing `catalog:client` line 10): `cd tools/<tool> && pnpm exec tsx cli.ts ...`. Do NOT run from repo root — pnpm exec resolves tsx via per-tool node_modules; no root-level workspace until Phase 4.

---

## Shared Patterns

### Pattern S-1: Deterministic JSON write
**Source:** `tools/asset-catalog/src/emit.ts` lines 45-70
**Apply to:** Every JSON output in Phase 3 (`protocol.json`, `save-formats.json`, `parity-checklist.json`, asset-catalog `index.json`)

```typescript
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';
  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;
}
```

**Determinism contract (D-15/D-16):** sorted keys, 2-space indent, single trailing LF, LF-only line endings (CRLF stripped on Windows). Zero `Date.now()` / `new Date()` references — enforced by `lint-protocol.mjs` round-trip check.

---

### Pattern S-2: Cross-platform CLI integration test
**Source:** `tools/asset-catalog/tests/integration/cli.test.ts` lines 33-51
**Apply to:** `tools/protocol-doc/tests/integration/cli.test.ts`, `tools/save-format-doc/tests/integration/cli.test.ts`

```typescript
function runCli(args: string[]): CliResult {
  const isWindows = process.platform === 'win32';
  const res = spawnSync('pnpm', ['exec', 'tsx', 'cli.ts', ...args], {
    cwd: toolDir,                  // load-bearing: tsx is per-tool, NOT repo-root
    encoding: 'utf-8',
    shell: isWindows,              // load-bearing for pnpm on Windows
  });
  return { code: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
}
```

**Critical Windows note** (asset-catalog cli.test.ts lines 36-45): `cwd: toolDir`, NOT `cwd: repoRoot` — root has no tsx (no pnpm workspaces until Phase 4). Phase 2 UAT (commit `ac45781`) hardened this.

---

### Pattern S-3: AUTOGEN-block scan + rewrite
**Source:** `tools/asset-catalog/src/autogen.ts` lines 33-103 (scan) + lines 352-403 (rewrite)
**Apply to:** Every autogen-rendered MD in Phase 3 (`protocol.md`, `save-formats.md`, `parity-checklist.md`, all subsystem MDs with autogen tables)

**Marker grammar** (autogen.ts lines 46-48) — preserve regex exactly:
```typescript
const AUTOGEN_START_RE = /^<!--\s*AUTOGEN:([A-Za-z0-9_-]+):start(\s+[^-]*?)?\s*-->\s*$/;
const AUTOGEN_END_RE = /^<!--\s*AUTOGEN:([A-Za-z0-9_-]+):end\s*-->\s*$/;
```

**Reverse-order rewrite** (autogen.ts lines 369-394): apply replacements in reverse start-line order so earlier line indices remain valid as we splice.

**Front-matter parser** (autogen.ts lines 112-122): no js-yaml dep — minimal scalar-only parser. Reuse from asset-catalog if PHASE 4 workspaces; for Phase 3, copy the function into `tools/protocol-doc/src/autogen.ts`.

---

### Pattern S-4: Subsystem MD discovery
**Source:** `tools/asset-catalog/src/autogen.ts` lines 414-431
**Apply to:** `tools/protocol-doc/src/autogen.ts`, `tools/save-format-doc/src/autogen.ts`

```typescript
export function findSubsystemMds(docsDir: string): Array<{ path: string; subsystem: string }> {
  if (!existsSync(docsDir) || !statSync(docsDir).isDirectory()) return [];
  const out: Array<{ path: string; subsystem: string }> = [];
  for (const entry of readdirSync(docsDir).sort()) {
    if (!entry.endsWith('.md') || entry === 'README.md') continue;
    const p = join(docsDir, entry);
    if (!statSync(p).isFile()) continue;
    const md = readFileSync(p, 'utf-8');
    const fm = readFrontMatter(md);
    const subsystem = fm['subsystem'] ?? entry.replace(/\.md$/, '');
    out.push({ path: p, subsystem });
  }
  return out;
}
```

**`asset-catalog/` subdirectory is intentionally NOT walked** (autogen.ts lines 412-413) — auto-generated indexes have no AUTOGEN markers.

---

### Pattern S-5: Three-candidate path resolution
**Source:** `tools/asset-catalog/src/emit.ts` lines 401-418 + 454-464
**Apply to:** Every CLI subcommand in `tools/protocol-doc/`, `tools/save-format-doc/` that needs to find `extracted/server-5-4/` from variable cwds

```typescript
const toolDirSibling = fileURLToPath(
  new URL('../../../extracted/server-5-4/', import.meta.url),
).replace(/[\\/]$/, '');
const candidates = [
  'extracted/server-5-4',                                              // (a) CWD-relative (repo-root invocation)
  join(docsDir, '..', '..', 'extracted', 'server-5-4'),                // (b) docsDir-sibling
  toolDirSibling,                                                      // (c) tool-relative (lint-docs.mjs tmp-dir invocation)
];
const sourceDir = candidates.find(p => existsSync(p));
```

**Critical (per emit.ts lines 401-418 comment):** the third candidate is required for `lint-subsystem-mds.mjs` integration tests where `docsDir` is a tmp copy. Soft-fail (process.stderr warn + return) when extraction is missing rather than throwing — preserves CI on docs-only checkouts.

---

### Pattern S-6: Michael Nygard ADR format
**Source:** `docs/adr/0001-client-engine.md` (full file) + `tools/asset-catalog/scripts/lint-adr.mjs` lines 50-62
**Apply to:** `docs/adr/0002-persistence-layer.md`, `docs/adr/0003-canonical-snapshot.md`

**Required H2 sections** (lint-adr.mjs lines 50-55) — verbatim:
- `## Status`
- `## Context`
- `## Decision`
- `## Consequences`

**Header convention** (0001-client-engine.md lines 1-5):
```markdown
# ADR NNNN: <title>

**Date:** YYYY-MM-DD
**Phase:** NN <open|close>
```

**`## Status` body** (0001-client-engine.md lines 7-10): "Accepted — locked at end of Phase NN (REQ-ID). Re-evaluation gate: <when>."

**Caveat:** `lint-adr.mjs` as-shipped requires ≥3 unique MX-* MATRIX citations (Phase 2-specific). Phase 3 ADRs need either (a) `lint-adr.mjs` extended with `--no-matrix` flag, or (b) a separate `lint-adr-phase3.mjs`. **Recommend (a) — smaller diff.**

---

### Pattern S-7: Anti-port reference (REJECTED-AS-PORTED)
**Source:** `docs/extracted-engine/admin-anti-port.md` lines 1-50
**Apply to:** `docs/extracted-server/admin-anti-port.md`

**Front-matter:** `mvp: no`, `subsystem: admin`, `status: anti-port-reference`
**Top-warning callout:** Block-quote referencing CLAUDE.md hard rule #3
**Per-command 4-column table:** `Command | Original behaviour | REJECTED-AS-PORTED reason | Rebuild equivalent`

**Phase 3 extension (per D-20):** add concrete TS intent shape per modernized command (kick, mute, ban, assign-role, view-audit-log, mb-moderate, account-recover) inline below each REJECTED row. Phase 7 PAR-07 implements; Phase 4 SRV-12 stubs.

---

### Pattern S-8: Subsystem MD with functional-cluster + AUTOGEN script-roster
**Source:** `docs/extracted-engine/client-networking.md` lines 1-80
**Apply to:** All `docs/extracted-server/{account-auth,world-simulation,room-management,chat,persistence,packet-protocol,client-server-bridge,message-board}.md`

**Front-matter** (lines 1-4):
```markdown
---
mvp: yes|no
subsystem: <name>
---
```

**Body shape** (lines 6-55):
- One paragraph per behavior cluster (per D-16 functional-cluster grouping)
- 5-30 line GML snippets with `script:line` citations
- "Pattern: thin wrapper into the wiki" subsection cross-linking `decomp/wiki/*.md` (Phase 1 D-19)

**AUTOGEN script-roster at the bottom** (lines 56-80):
```markdown
## Scripts referenced in this subsystem

<!-- AUTOGEN:scripts:start -->
| Script ID | Name | Lines | Used in objects |
|-----------|------|-------|------------------|
... rendered from SUBSYSTEM-MAP.json + asset-catalog index.json
<!-- AUTOGEN:scripts:end -->
```

**Generators registered in `tools/asset-catalog/src/autogen.ts` already** (lines 201-264): `scripts`, `objects`, `gml-functions`. Server-side reuse — re-invoke `tools/asset-catalog regen-autogen ../../docs/extracted-server` (per D-17).

---

## No Analog Found

Files with no close match in the codebase (planner falls back to RESEARCH.md guidance):

| File | Role | Data Flow | Reason |
|---|---|---|---|
| `docs/extracted-server/tables.ts` | Drizzle schema | DDL declaration | First DB code in repo. Use Drizzle 0.45.2 docs (`orm.drizzle.team`) per RESEARCH.md §Standard Stack lines 144-145. Per-column citation pattern is Phase 3-specific (D-13). |
| `docs/extracted-server/schema.sql` | DDL | n/a | Generated by `drizzle-kit generate` from `tables.ts`. No prior SQL in repo. |
| `docs/extracted-server/0001_baseline.sql` | drizzle-kit migration | n/a | Generated by `drizzle-kit generate`. No prior migrations in repo. |
| `tools/protocol-doc/src/derive/xls-hints.ts` | CSV/XLS reader | file-I/O | New pattern. Researcher recommends CSV-conversion path (RESEARCH lines 130-134) — falls back to stdlib CSV parsing (split-on-comma with quote handling). No `xlsx` dep needed. |
| `tools/protocol-doc/src/emit/typescript.ts` | TS emitter | file-I/O write (string template) | Closest analog is `tools/extract-gmd/src/emit/manifest.ts` line-array string assembly — same shape (`lines.push(...).join('\n')`), just different output content. |

---

## Metadata

**Analog search scope:**
- `tools/asset-catalog/` (Phase 2 reference — primary analog source)
- `tools/extract-gmd/` (Phase 1 reference — secondary analog source)
- `docs/extracted-engine/` (Phase 2 docs — direct parallel for `docs/extracted-server/`)
- `docs/adr/0001-client-engine.md` (only existing ADR)
- `decomp/wiki/` (errata target + thin-wrapper cross-link target)
- `.planning/phases/02-client-engine-documentation/02-PATTERNS.md` (sibling pattern map style reference)

**Files scanned (read in this pass):**
- `tools/asset-catalog/cli.ts`, `package.json`, `tsconfig.json`, `vitest.config.ts`
- `tools/asset-catalog/src/{types,emit,autogen,load}.ts`
- `tools/asset-catalog/scripts/{lint-docs,lint-matrix,lint-adr}.mjs`
- `tools/asset-catalog/tests/integration/cli.test.ts`
- `tools/extract-gmd/cli.ts`, `tools/extract-gmd/src/types.ts`
- `docs/extracted-engine/{client-networking,admin-anti-port}.md` (and listing)
- `docs/adr/0001-client-engine.md`
- `package.json` (root)
- `.planning/phases/02-client-engine-documentation/02-PATTERNS.md` (style alignment)

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