---
phase: 01-extraction
plan: 04
subsystem: extract-gmd / timelines + objects + rooms + orchestrator
tags: [extract, timelines, objects, rooms, orchestrator, dnd-consumer, lateralgm-port]
requires:
  - tools/extract-gmd/src/reader/BinaryReader.ts
  - tools/extract-gmd/src/dnd/readAction.ts
  - tools/extract-gmd/src/reader/header.ts
  - tools/extract-gmd/src/reader/settings.ts
  - tools/extract-gmd/src/reader/sounds.ts
  - tools/extract-gmd/src/reader/sprites.ts
  - tools/extract-gmd/src/reader/backgrounds.ts
  - tools/extract-gmd/src/reader/paths.ts
  - tools/extract-gmd/src/reader/scripts.ts
  - tools/extract-gmd/src/reader/datafiles.ts
  - tools/extract-gmd/src/reader/fonts.ts
  - tools/extract-gmd/tests/fixtures/build-fixtures.ts
provides:
  - readActions(r): DnDAction[] — outer DnD list wrapper [int32 sver][int32 count] + N action nodes (W6 dual-sver framing)
  - readTimelines(r, ver): Timeline[] — block 10 reader; per-moment step + DnD action list
  - readObjects(r, ver): GmObject[] — block 11 reader; 12 event types, sentinel-terminated event-number lists per type, MAX_EVENTS_PER_TYPE=1024 sanity cap (T-04-01)
  - readRooms(r, ver): Room[] — block 12 reader; 8 background layers + 8 view slots + instances + tiles + editor-info trailer (W5 fragility)
  - readProjectFile(buf): ProjectFile — orchestrator walking all 12 blocks in strict native order; parameterized only by Buffer (EXT-05)
affects:
  - tools/extract-gmd/src/types.ts — Timeline/GmObject/Room interfaces concretized; new TimelineMoment/ObjectEvent/RoomInstance/RoomTile/RoomBackgroundLayer/RoomView interfaces
tech-stack:
  added: []
  patterns:
    - "Dual sub-version framing for DnD action lists: outer [sver][count] in readActions, inner [sver][libId]... per readAction (intentional in LateralGM GmFileReader)"
    - "Sentinel-terminated event-number list per type bounded by MAX_EVENTS_PER_TYPE=1024 (W4 / T-04-01 mitigation; for-loop with explicit cap, not while(true))"
    - "Defensive trailing-bytes capture in orchestrator: r.atEnd() → gameInfo.{trailingBytes, cursorAtBlockEnd} surfaces drift for triage (T-04-02)"
    - "Bare-Buffer parameterization (no per-source branching) for readProjectFile — same code reads client and server .gmd"
    - "Editor-info trailer fields read-and-discarded for cursor alignment (D-15 — pure GUI state never enters extracted tree)"
key-files:
  created:
    - tools/extract-gmd/src/dnd/readActions.ts
    - tools/extract-gmd/src/reader/timelines.ts
    - tools/extract-gmd/src/reader/objects.ts
    - tools/extract-gmd/src/reader/rooms.ts
    - tools/extract-gmd/src/reader/readProjectFile.ts
    - tools/extract-gmd/tests/reader/timelines.test.ts
    - tools/extract-gmd/tests/reader/objects.test.ts
    - tools/extract-gmd/tests/reader/rooms.test.ts
    - tools/extract-gmd/tests/reader/readProjectFile.test.ts
  modified:
    - tools/extract-gmd/src/types.ts
    - tools/extract-gmd/tests/fixtures/build-fixtures.ts
decisions:
  - "Event framing: 12 event types per object (Create/Destroy/Alarm/Step/Collision/Keyboard/Mouse/Other/Draw/KeyPress/KeyRelease/Trigger), each with a sentinel(-1)-terminated event-number list — matches LateralGM GmFileReader.readObjects 1:1."
  - "Sanity cap MAX_EVENTS_PER_TYPE=1024 (W4 / T-04-01) bounds the sentinel loop with `for (n=0; n<MAX; n++)` + post-loop throw rather than `while (true)`. The throw path is defensive only — no real game has >1024 events of a single type; the cap is two orders of magnitude above realistic. Throw path NOT exercised by a positive test (would require constructing a 1024+-event fixture for negative case; deferred — bounds-check from BinaryReader catches malformed input first)."
  - "W6 dual-sver framing in readActions documented inline: outer sver versions list shape, inner sver versions per-action node. Cross-reference comment in src/dnd/readActions.ts header."
  - "Editor-info trailer field count derived from LateralGM v530 path: 3 bools (rememberEditorInfo, ...) + 2 int32 (editor w/h) + 7 bools (showGrid/Objects/Tiles/Backgrounds/Foregrounds/Views, deleteUnderlyingObjects/Tiles) + 3 int32 (currentTab, scrollBarX/Y) = 14 fields total (W5 — flagged as fragile; if plan 07 hits cursor drift, revise here first and capture tests/fixtures/v530-room-trailer.bin baseline). Current tally: 1 bool + 2 int32 + 8 bools (+ 1 already counted) + 3 int32 = matches reader sequence: rememberEditorInfo, editorWidth, editorHeight, showGrid, showObjects, showTiles, showBackgrounds, showForegrounds, showViews, deleteUnderlyingObjects, deleteUnderlyingTiles, currentTab, scrollBarX, scrollBarY (14 reads)."
  - "Orchestrator gameInfo defensive shape: `{ trailingBytes: number, cursorAtBlockEnd: number }` instead of unparsed Buffer — keeps the type lightweight; plan 07 may upgrade to structured shape once real BNO trailing fields observed."
  - "PF-02 test fixture corrected: Int32 magic value cannot exceed INT32_MAX (initial draft used 0xdeadbeef which overflows). Bad-magic now uses 0x12345678."
metrics:
  duration: 6min
  completed: 2026-05-02
---

# Phase 01 Plan 04: Timelines + Objects + Rooms + readProjectFile Orchestrator Summary

Block 10 (timelines), 11 (objects with sentinel-terminated event-number lists per type, capped at MAX_EVENTS_PER_TYPE=1024), and 12 (rooms with editor-info trailer) readers complete. The `readProjectFile` orchestrator walks all 12 top-level blocks in strict native order from `decomp/wiki/03-gmd-format.md` — parameterized only by Buffer so the same code reads client and server `.gmd` (EXT-05). End-to-end fixture parse populates every block. EXT-02 (DnD diffable graphs) and EXT-03 (room layouts) satisfied at parser level (emit layer in plan 05 will surface them to disk). Cumulative test count: 94/94 green; tsc strict clean.

## Tasks

### Task 1 — Timelines + Objects (DnD-bearing readers)

**Commits:** `e1fce5b` test RED, `d822935` feat GREEN

- `src/dnd/readActions.ts` (new) — wraps the per-action-list [outer sver][count] + N readAction nodes. **W6 documented inline:** the dual-sver framing (outer versions list shape, inner versions per-action node) is intentional in LateralGM `GmFileReader.readActions`; collapsing them would break parity.
- `src/reader/timelines.ts` (new) — block 10 reader. Standard exists-flag pattern + per-moment `[step][readActions]`. v800+ "last changed" timestamp skipped per D-15.
- `src/reader/objects.ts` (new) — block 11 reader. Standard exists-flag pattern + 12 event types, each with a sentinel(-1)-terminated event-number list. Sanity cap `MAX_EVENTS_PER_TYPE=1024` bounds the loop with `for` + post-loop throw (W4 / T-04-01) rather than `while (true)`.
- `src/types.ts` — concretized `Timeline`, `GmObject`, `ObjectEvent`, `TimelineMoment` interfaces; introduced room-related interfaces in same edit (consumed by task 2).
- `tests/fixtures/build-fixtures.ts` — added `writeActionsBlock`, `writeTimelinesBlock`, `writeObjectsBlock`. Object writer groups events by type and writes sentinel-terminated lists per type (12 types).

10 new tests (TL-01..TL-04, OBJ-01..OBJ-06) passed first try after RED commit. No deviations.

### Task 2 — Rooms + readProjectFile Orchestrator

**Commits:** `b55670b` test RED, `f8fa0f3` feat GREEN

- `src/reader/rooms.ts` (new) — block 12 reader. Largest single block: meta (caption/width/height/snap/isometric/speed/persistent/bgColor/drawBgColor/creationCode) + N background layers + N view slots + instances[] + tiles[] + editor-info trailer (14 fields read-and-discarded for cursor alignment). **W5 fragility note carried** — the editor-info trailer is the most likely site of cursor drift when plan 07 runs against real BNO `.gmd`. Triage runbook: capture `tests/fixtures/v530-room-trailer.bin` from the first room of `BN Online Client 5-8.gmd` and diff against this writer's output.
- `src/reader/readProjectFile.ts` (new) — orchestrator. Calls `readHeader` then chains `readSettings → readSounds → readSprites → readBackgrounds → readPaths → readScripts → readDataFiles → readFonts → readTimelines → readObjects → readRooms` in exact native order. Parameterized only by `Buffer` — no per-source branching (EXT-05). Defensive `gameInfo` capture surfaces unread trailing bytes as `{ trailingBytes, cursorAtBlockEnd }` (T-04-02 cursor invariant).
- `tests/fixtures/build-fixtures.ts` — added `writeRoomsBlock` (8 default backgrounds + 8 default views) and `buildTinyFull()` end-to-end fixture (1 of each resource across all 12 blocks).

10 new tests (RM-01..RM-06, PF-01..PF-04). One small RED-phase test bug (Int32 overflow on `0xdeadbeef`) caught and corrected — no behavior change. No deviations.

## Acceptance Criteria

- [x] `tools/extract-gmd/src/dnd/readActions.ts` exists and exports `readActions(r): DnDAction[]`
- [x] `tools/extract-gmd/src/reader/timelines.ts` exports `readTimelines(r, ver): Timeline[]`
- [x] `tools/extract-gmd/src/reader/objects.ts` exports `readObjects(r, ver): GmObject[]`; both `MAX_EVENTS_PER_TYPE` AND `EVENT_TYPE_COUNT` greppable (W4)
- [x] `tools/extract-gmd/src/reader/rooms.ts` exports `readRooms(r, ver): Room[]`
- [x] `tools/extract-gmd/src/reader/readProjectFile.ts` exports `readProjectFile(buf): ProjectFile`
- [x] All 12 block readers chained in strict native order (settings → sounds → sprites → backgrounds → paths → scripts → datafiles → fonts → timelines → objects → rooms)
- [x] `tools/extract-gmd/src/types.ts` contains `interface TimelineMoment`, `interface ObjectEvent`, `interface Timeline`, `interface GmObject`, `interface RoomInstance`, `interface RoomTile`, `interface RoomBackgroundLayer`, `interface RoomView`, `interface Room`
- [x] All 20 new tests (TL-01..TL-04 + OBJ-01..OBJ-06 + RM-01..RM-06 + PF-01..PF-04) pass
- [x] Full suite 94/94 green; no regression in plans 01-03 tests
- [x] `npx tsc --noEmit` exits 0 (strict TS clean)
- [x] Sentinel-terminated event-list framing: `eventNumber === -1` AND `MAX_EVENTS_PER_TYPE` greps both hit (W4 — explicit cap, not weak OR)
- [x] Cursor-invariant assertion in PF-04: `r.atEnd()` true + `gameInfo === undefined` for clean fixture (no trailing bytes leaked)
- [x] DnD action lookup wired (objects + timelines call `readActions` → `readAction` → action-ids.json from plan 03)

## Open Questions for Plan 07 (real-data parse)

1. **Editor-info trailer field count drift (W5)** — does v530 BNO `.gmd` have exactly the 14 fields ported here? First triage site if cursor mismatch fires mid-rooms-block.
2. **Object event-list framing** — does BNO use the standard 12 event types + -1 sentinel? Or a different framing (fixed count per type)? OBJ-03..OBJ-06 cover sentinel framing; mismatch will surface as either bounds throw (BinaryReader) or `MAX_EVENTS_PER_TYPE` cap throw.
3. **Datafiles block presence in v530** — `readDataFiles` opens with `r.atEnd() → return []` defensive guard. v530 BNO is expected to omit the block entirely; if it's present (re-saved in newer IDE), the reader still works.
4. **Trailing bytes** — what does `gameInfo.trailingBytes` measure on real BNO `.gmd`? Plan 07 may type these (likely `lastInstanceId`, `lastTileId`, game info text).

## Deviations from Plan

None — plan executed exactly as written. One small RED-phase test correction (Int32 overflow in PF-02 bad-magic value `0xdeadbeef → 0x12345678`) is not a deviation; it's a test-author correction made during the RED→GREEN transition.

## Self-Check: PASSED

- All 5 created files exist on disk: `readActions.ts`, `timelines.ts`, `objects.ts`, `rooms.ts`, `readProjectFile.ts`
- All 4 test files exist on disk: `timelines.test.ts`, `objects.test.ts`, `rooms.test.ts`, `readProjectFile.test.ts`
- All 4 commits in `git log`: `e1fce5b` (test RED T1), `d822935` (feat GREEN T1), `b55670b` (test RED T2), `f8fa0f3` (feat GREEN T2)
- 94/94 tests green via `npx vitest run`; tsc strict clean
- 11/11 reader function names present in `readProjectFile.ts` (verified via Grep)
- W4 acceptance: `MAX_EVENTS_PER_TYPE` (7 hits) + `EVENT_TYPE_COUNT` (3 hits) + `eventNumber === -1` (2 hits) all greppable in `objects.ts`
