---
phase: 01-extraction
plan: 02
subsystem: reader
tags: [typescript, vitest, binary-parsing, gamemaker, lateralgm, exists-flag-pattern, version-dispatch]

requires:
  - phase: 01-extraction
    plan: 01
    provides: BinaryReader, readHeader, BufferWriter (now exported), canonical types.ts shapes (Sound{audioBytes,audioExt}, Datafile{bytes}, Font{glyphs}), v530 file-version validation

provides:
  - readSettings(r, ver): Settings — block 2 singleton with v542+/v600+/v800+ dispatch
  - readSounds(r, ver): Sound[] — block 3, embedded audio buffer (D-13) or path-only
  - readPaths(r, ver): GmPath[] — block 6, waypoint sequences (x,y,speed doubles)
  - readScripts(r, ver): Script[] — block 7, GML plaintext preserved verbatim (D-15)
  - readDataFiles(r, ver): Datafile[] — block 8, version-guarded with atEnd() defense
  - readFonts(r, ver): Font[] — block 9, OS-font metadata (v530 has no raster strip per D-14)
  - 6 fixture-builder writers: writeSettingsBlock, writeScriptsBlock, writePathsBlock, writeSoundsBlock, writeDataFilesBlock, writeFontsBlock
  - BufferWriter.writeDouble() primitive added to fixture builder
  - Concrete typed shapes for Settings, GmPath/PathPoint, Script, Sound, Datafile, Font in src/types.ts

affects: [01-03 (sprites/backgrounds — same exists-flag pattern), 01-04 (orchestrator dispatch + remaining blocks 4/5/10/11/12), 01-05 (emit layer — consumes audioBytes/bytes/glyphs by canonical name), 01-07 (real .gmd integration)]

tech-stack:
  added:
    - (none — same vitest 4.1.5 / typescript 5.6.3 / sharp 0.34.5 chain as plan 01-01)
  patterns:
    - exists-flag pattern uniformly applied across 5 list-shaped blocks (sounds, paths, scripts, datafiles, fonts)
    - positional ID semantics — slot index = `id`, deleted slots consume their ID (D-15 / Pitfall 5)
    - version dispatch — every reader honors `if (ver >= 800) r.skip(8)` for "last changed" timestamp; sub-block versions drive field presence (e.g. settings sver>=542 reads `interpolate`)
    - defensive atEnd() guard in readDataFiles — silent return [] for v530 source where the block is wholly absent
    - readBytes-bounded embedded buffers (T-01-01 mitigation extends to T-01-09 sound bodies + T-01-10 datafile bodies)
    - dot-stripped audioExt derivation in readSounds (".wav" → "wav") — feeds emit/tree.ts naming directly
    - fixture-as-test-input pattern — BufferWriter exported so tests build minimal block fixtures inline (no on-disk .gmd needed for unit tests)

key-files:
  created:
    - tools/extract-gmd/src/reader/settings.ts
    - tools/extract-gmd/src/reader/scripts.ts
    - tools/extract-gmd/src/reader/paths.ts
    - tools/extract-gmd/src/reader/sounds.ts
    - tools/extract-gmd/src/reader/datafiles.ts
    - tools/extract-gmd/src/reader/fonts.ts
    - tools/extract-gmd/tests/reader/settings.test.ts
    - tools/extract-gmd/tests/reader/scripts.test.ts
    - tools/extract-gmd/tests/reader/paths.test.ts
    - tools/extract-gmd/tests/reader/sounds.test.ts
    - tools/extract-gmd/tests/reader/datafiles.test.ts
    - tools/extract-gmd/tests/reader/fonts.test.ts
  modified:
    - tools/extract-gmd/src/types.ts (Settings/GmPath/PathPoint/Script/Sound/Datafile/Font: stub → concrete; B3 canonical field names preserved)
    - tools/extract-gmd/tests/fixtures/build-fixtures.ts (export BufferWriter; add writeDouble; add 6 block writers)

key-decisions:
  - "Reader signature uniformly (r: BinaryReader, ver: number) → ResourceType[] (settings is the singleton exception). Mechanically replicable in plans 01-03/01-04."
  - "settings.ts reads `if (ver >= 800) r.skip(8)` BEFORE the sub-block version, mirroring GmFileReader.readSettings. The plan's example sketch had the order reversed; I followed the LateralGM source semantics."
  - "datafiles.ts opens with `if (r.atEnd()) return []` — defensive belt-and-suspenders for v530 source files that omit the block entirely. Real BNO .gmd integration in plan 01-07 will exercise this branch."
  - "audioExt is derived from fileType (dot-stripped, defaulting to 'bin' on empty). Sets the canonical extension that emit/tree.ts (plan 05) writes as `audio.<ext>`."
  - "Sub-block version (sver) chosen per real LateralGM precedents: sounds=440, scripts=400, paths=530, datafiles=440, fonts=540. Real BNO files may differ slightly; integration in plan 01-07 will catch any drift."
  - "Vitest --reporter=basic does not exist in Vitest 4 (Rule 3 deviation). Used the default reporter; results identical."

patterns-established:
  - "exists-flag pattern: count → for-loop → readBool gate → name → ver-skip → sver → body. Replicate verbatim for sprites (block 4), backgrounds (block 5), timelines (block 10), objects (block 11), rooms (block 12)."
  - "Per-task TDD discipline: RED commit (test only, fails) → GREEN commit (impl, passes). Cumulative test count rises monotonically."
  - "Reader scope discipline: NEVER touch buffers via Buffer.from() inside readers — every byte goes through BinaryReader. Verifiable via grep 'Buffer\\.from' in src/reader/."

requirements-completed: [EXT-01, EXT-04]

duration: 6min
completed: 2026-05-02
---

# Phase 1 Plan 02: Six Simple Block Readers Summary

**Six pure parsers (settings, sounds, paths, scripts, datafiles, fonts) covering blocks 2/3/6/7/8/9 of the 12-block .gmd format — exists-flag pattern uniformly applied; embedded audio + datafile bodies preserved as canonical `audioBytes` / `bytes` for plan 05's emit layer; 19 new unit tests round-trip programmatic fixtures.**

## Performance

- **Duration:** ~6 min (much faster than plan 01-01's 25min — the BinaryReader + types contract from plan 01 made each reader a near-mechanical port)
- **Started:** 2026-05-02T23:12:52Z
- **Completed:** 2026-05-02T23:18:39Z
- **Tasks:** 2/2 complete (each task RED → GREEN as separate commits = 4 commits total)
- **Files:** 12 created (6 readers + 6 test files), 2 modified (types.ts, build-fixtures.ts)
- **Lines of code:** ~580 (impl: ~190; tests: ~270; fixture writers added: ~120)
- **Tests:** 19/19 new passing (SET-01..SET-03, SCR-01..SCR-03, PTH-01..PTH-03, SND-01..SND-04, DAT-01..DAT-03, FNT-01..FNT-03)
- **Cumulative tests:** 51/51 passing across plan 01-01 + 01-02

## Accomplishments

- **6 readers green:** settings, sounds, paths, scripts, datafiles, fonts. All ported from `org.lateralgm.file.GmFileReader` (LateralGM 1.8.234) preserving call order semantics. Block-2/3/6/7/8/9 of the 12-block sequence covered; block-4 (sprites), 5 (backgrounds), 10 (timelines), 11 (objects), 12 (rooms) remain for plans 01-03 + 01-04.
- **Exists-flag pattern uniformly applied:** All 5 list-shaped blocks (sounds, paths, scripts, datafiles, fonts) use the identical `count → loop → readBool gate → name → ver-skip → sver → body → push({id, ...})` skeleton. Slot index = positional ID; deleted slots consume IDs (D-15 / Pitfall 5).
- **Version dispatch consistent:** Every reader implements `if (ver >= 800) r.skip(8)` for the "last changed" timestamp (read-and-discarded per D-15). Sub-block versions drive field presence — e.g. `Settings.interpolate` is only read when `sver >= 542`.
- **B3 canonical names preserved:** `Sound{audioBytes, audioExt}` (NOT `data`/`fileType`-as-extension), `Datafile{bytes}` (NOT `data`), `Font{glyphs}` (structured `{width, height, rgba}` not flat `glyphData`). Plan 05's emit/tree.ts can read these field names directly without any cast or rename.
- **Concrete type shapes:** `Settings`, `GmPath`/`PathPoint`, `Script`, `Sound`, `Datafile`, `Font` upgraded from stub `{ [k]: unknown }` to fully-typed interfaces with the documented field set. Index signature retained on Sound/Datafile/Font so plan 04's orchestrator can append v600+ extras without re-touching these definitions.
- **Fixture builder extended:** `BufferWriter` now exported (tests build minimal block fixtures inline); `writeDouble` primitive added; 6 new block writers (`writeSettingsBlock`, `writeScriptsBlock`, `writePathsBlock`, `writeSoundsBlock`, `writeDataFilesBlock`, `writeFontsBlock`). Each writer mirrors the corresponding reader's call order so round-trip tests are trivially valid.
- **Defensive datafiles.ts:** Opens with `if (r.atEnd()) return []` — silently handles the v530 case where the block is wholly absent from the source file. Real BNO .gmd integration (plan 01-07) will exercise this branch.
- **Threat mitigations:** T-01-09 (malicious sound dataLen) + T-01-10 (malicious datafile dataLen) both bounded by BinaryReader.readBytes inheriting T-01-01's check. T-01-11 (filesystem-path leakage in fileName) accepted as historical metadata per the plan's threat register.

## Task Commits

Each task was committed atomically as a TDD pair (RED test commit + GREEN impl commit):

1. **Task 1 RED — settings + scripts + paths failing tests** — `8f04f9b` (test)
2. **Task 1 GREEN — settings + scripts + paths impl** — `9d326f2` (feat)
3. **Task 2 RED — sounds + datafiles + fonts failing tests** — `f173d82` (test)
4. **Task 2 GREEN — sounds + datafiles + fonts impl** — `bd03c3f` (feat)

(Plan metadata commit follows this SUMMARY.)

## Files Created/Modified

**Task 1 (commits `8f04f9b` + `9d326f2`):**
- `tools/extract-gmd/src/reader/settings.ts` — port of GmFileReader.readSettings; v542+ interpolate dispatch; v800+ timestamp skip
- `tools/extract-gmd/src/reader/scripts.ts` — port of readScripts; GML plaintext verbatim
- `tools/extract-gmd/src/reader/paths.ts` — port of readPaths; (x,y,speed) doubles
- `tools/extract-gmd/tests/reader/settings.test.ts` — SET-01..SET-03 (3 tests)
- `tools/extract-gmd/tests/reader/scripts.test.ts` — SCR-01..SCR-03 (3 tests)
- `tools/extract-gmd/tests/reader/paths.test.ts` — PTH-01..PTH-03 (3 tests)
- `tools/extract-gmd/src/types.ts` — Settings/GmPath/PathPoint/Script: stub → concrete
- `tools/extract-gmd/tests/fixtures/build-fixtures.ts` — export BufferWriter; add writeDouble + writeSettingsBlock + writeScriptsBlock + writePathsBlock

**Task 2 (commits `f173d82` + `bd03c3f`):**
- `tools/extract-gmd/src/reader/sounds.ts` — port of readSounds; embedded audioBytes; bounded readBytes
- `tools/extract-gmd/src/reader/datafiles.ts` — port of readDataFiles; defensive atEnd(); bytes (NOT data) field
- `tools/extract-gmd/src/reader/fonts.ts` — port of readFonts; metadata only (no raster strip in v530)
- `tools/extract-gmd/tests/reader/sounds.test.ts` — SND-01..SND-04 (4 tests)
- `tools/extract-gmd/tests/reader/datafiles.test.ts` — DAT-01..DAT-03 (3 tests)
- `tools/extract-gmd/tests/reader/fonts.test.ts` — FNT-01..FNT-03 (3 tests)
- `tools/extract-gmd/src/types.ts` — Sound/Datafile/Font: stub → concrete (canonical field names preserved)
- `tools/extract-gmd/tests/fixtures/build-fixtures.ts` — add writeSoundsBlock + writeDataFilesBlock + writeFontsBlock

## Decisions Made

- **settings.ts reads `if (ver >= 800) r.skip(8)` BEFORE the sub-block version**, mirroring GmFileReader.readSettings semantics. The plan's example sketch had the timestamp skip AFTER the sub-block version read; I followed LateralGM's actual call order. The fixture builder writes the timestamp prefix in the same order (`writeSettingsBlock` with `withTimestamp: true`).
- **Datafiles version-guard at function entry, not in caller.** `readDataFiles` opens with `if (r.atEnd()) return []`. Plan 04's orchestrator can call us unconditionally; we silently no-op on v530 sources that omit the block. Belt-and-suspenders for the most common v530 case.
- **audioExt fallback to `'bin'` on empty fileType.** Defensive — never produces an empty extension. Sets the canonical filename `emit/tree.ts` writes (`audio.bin` rather than `audio.`).
- **Sub-block versions chosen from LateralGM precedents:** sounds=440, scripts=400, paths=530, datafiles=440, fonts=540. Real BNO files may have different sver values (e.g., 5.3a may use sver 410 for some blocks). Plan 01-07's real-`.gmd` integration will surface any drift; readers don't gate on sver internally yet (only on outer ver), so any reasonable value will parse.
- **Index signature `[k: string]: unknown` retained on Sound/Datafile/Font** in addition to the now-concrete fields. Plan 04's orchestrator can append v600+ tail-fields to these objects without re-editing the type definitions. Settings + GmPath + Script are fully closed (no index signature) since their field set is complete in v530.
- **Tests use BufferWriter directly** rather than calling `buildTinyEmpty/Script/Sprite()` — those high-level harness builders only emit headers (plan 01-01 stubs). Each reader test owns its fixture by composing block writers inline. This keeps each test self-contained and trivially debuggable.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Blocking] Vitest 4 dropped the `--reporter=basic` flag**
- **Found during:** Task 1 RED test run (`Failed to load custom Reporter from basic`)
- **Issue:** The plan's `<verify><automated>` block specified `npx vitest run --reporter=basic ...`. Vitest 4 removed the `basic` reporter (it lived in v1/v2/v3). Running with `--reporter=basic` produced a hard startup error.
- **Fix:** Dropped the `--reporter` flag; used the default reporter. Output is equivalent for our purpose (pass/fail + duration). All test commands in this plan ran as `npx vitest run tests/reader/<file>.test.ts`.
- **Files modified:** None (deviation lives in the run command, not in committed files).
- **Verification:** Re-running `npx vitest run` produces clean pass/fail summaries; same semantics as `--reporter=basic` would have given.
- **Committed in:** N/A (procedural, not code).

**2. [Rule 1 — Bug] Plan example for settings.ts had `sver` read AFTER timestamp skip — but the LateralGM source reads timestamp BEFORE sver**
- **Found during:** Task 1 GREEN implementation review
- **Issue:** The plan's `<action>` example for `readSettings` showed `const sver = r.readInt32LE(); if (ver >= 800) r.skip(8);` — i.e., read sver, THEN skip the timestamp. But in the actual `org.lateralgm.file.GmFileReader.readSettings`, the v800+ timestamp is the FIRST 8 bytes of the block (it precedes everything for v800+ files), and sver is read after. Following the plan literally would mis-align the cursor for v800+ source files.
- **Fix:** Swapped the order — `if (ver >= 800) r.skip(8); const sver = r.readInt32LE();`. Mirrored the same order in `writeSettingsBlock` so SET-02's round-trip test passes. (For v530 BNO source the order is irrelevant since the timestamp branch never executes; but plan 04's orchestrator must dispatch correctly for any real v800 input.)
- **Files modified:** `tools/extract-gmd/src/reader/settings.ts`, `tools/extract-gmd/tests/fixtures/build-fixtures.ts`
- **Verification:** SET-02 passes; cursor invariant `r.cursor === buf.length` holds.
- **Committed in:** `9d326f2` (folded into Task 1 GREEN — caught before commit).

**3. [Rule 2 — Critical] Sound `audioBytes` was conditionally assigned via `if (audioBytes !== undefined)` even though the local was already typed `Buffer | undefined`**
- **Found during:** Task 2 GREEN tsc check
- **Issue:** With `exactOptionalPropertyTypes: true` (set in tsconfig.json plan 01-01), `Sound { audioBytes?: Buffer }` does NOT accept `audioBytes: undefined` — only "field absent" or "field is Buffer". Naïvely assigning `sound.audioBytes = audioBytes` without a non-undefined narrowing would have raised TS2375.
- **Fix:** Wrapped both `audioBytes` and `bytes` assignments in `if (... !== undefined)` gates so the property is set only when defined. Same pattern in `datafiles.ts`. This both satisfies `exactOptionalPropertyTypes` AND ensures the SND-02 path-only test sees `out[0]?.audioBytes === undefined` (the property is genuinely absent, not present-with-undefined-value).
- **Files modified:** `tools/extract-gmd/src/reader/sounds.ts`, `tools/extract-gmd/src/reader/datafiles.ts`
- **Verification:** `npx tsc --noEmit` exits 0; SND-02 + DAT-03 both pass.
- **Committed in:** `bd03c3f` (folded into Task 2 GREEN).

---

**Total deviations:** 3 auto-fixed (1 blocking, 1 plan-example bug, 1 type-system correctness)
**Impact on plan:** All three are surface-level corrections that preserve plan intent. The Vitest reporter drop is ecosystem-level (mirrors plan 01-01's vitest 4 maxWorkers fix). The settings field-order fix is faithful to GmFileReader's actual semantics — the plan's example was a typo. The exactOptionalPropertyTypes guard is a TS strict-mode requirement inherited from plan 01-01's tsconfig and is the correct pattern for `B3 canonical` optional fields.

## Issues Encountered

- **Git CRLF warnings (carrying from plan 01-01):** Every committed source file triggered `LF will be replaced by CRLF the next time Git touches it`. Same Windows behavior — index stores LF, working tree gets CRLF on checkout. Not a correctness issue. Same caveat for plans 01-03+.
- **No real-`.gmd` integration in this plan:** Plan 01-07's job. Sub-block versions used in fixture writers (sounds=440, fonts=540, etc.) reflect LateralGM precedents, not actual bytes from BNO's v530 files. If the real files use different sver values, the readers will still work (no internal sver gating yet) but a future deviation may need to add sver-conditional field reads inside individual readers.

## Threat Model Coverage

| Threat ID | Mitigation | Verified by |
|-----------|------------|-------------|
| T-01-09 (Tampering, malicious sound dataLen) | `r.readBytes(dataLen)` inherits T-01-01's bound; throws on overrun | SND-01 round-trip + bounded `readBytes` from BR-08c/BR-13b |
| T-01-10 (DoS, malicious datafile dataLen) | Same `readBytes` bound | DAT-02 round-trip |
| T-01-11 (Information Disclosure, fileName paths) | Accepted as historical metadata; emit/tree.ts in plan 05 will surface to meta.json as-is | Documented; no code mitigation |

## User Setup Required

None. Reviewer can run `cd tools/extract-gmd && npx vitest run` (51 tests in <2s) and `npx tsc --noEmit` (clean) to reproduce locally.

## Next Phase Readiness

**Ready for plan 01-03 (sprites + backgrounds, ZLIB pixel buffers):**
- Exists-flag pattern is now mechanically replicable. Plans 01-03 + 01-04 should follow the identical skeleton: `count → loop → readBool gate → name → if(ver>=800) skip(8) → sver → body → push({id, ...})`. The block-body specifics (ZLIB-decompressed pixel strips for sprites; DnD action arrays for objects/timelines) are the only novel parts.
- `BufferWriter` is now exported and supports all required primitives (writeBool, writeInt32LE, writeUint32LE, writeDouble, writeStr, writeBytes). Plan 01-03 only needs to add `writeSpritesBlock` + `writeBackgroundsBlock` writers (which will additionally need to handle ZLIB-compressed pixel buffer payloads — see BinaryReader.decompress already in place).
- All 6 readers compose cleanly into plan 01-04's orchestrator: each takes `(r: BinaryReader, ver: number)` and returns its respective `Resource[]` (or singleton for `Settings`). The orchestrator just calls them in block-order (header → settings → sounds → sprites → bgs → paths → scripts → datafiles → fonts → timelines → objects → rooms → gameInfo).

**Concerns / forwarded notes:**
- Real BNO `.gmd` files MAY use sub-block versions different from the LateralGM-precedent values used in the fixtures (sounds=440, fonts=540, etc.). Plan 01-07 will surface any drift. If a reader needs to gate on `sver` (rather than just outer `ver`), add the dispatch INSIDE the per-reader function — do not add a new orchestrator-level branch.
- The plan's `<verify><automated>` blocks specified `--reporter=basic` which Vitest 4 doesn't have. Plans 01-03 onwards should drop the `--reporter` flag.
- Sound `effects/volume/pan/preload` ordering follows LateralGM. If real BNO files have different field ordering (some 5.x revisions reorder these), SND-01 will fail at the cursor invariant check and a deviation can correct.

## Self-Check: PASSED

**Files created — verified exist:**
- `tools/extract-gmd/src/reader/settings.ts` ✓
- `tools/extract-gmd/src/reader/scripts.ts` ✓
- `tools/extract-gmd/src/reader/paths.ts` ✓
- `tools/extract-gmd/src/reader/sounds.ts` ✓
- `tools/extract-gmd/src/reader/datafiles.ts` ✓
- `tools/extract-gmd/src/reader/fonts.ts` ✓
- `tools/extract-gmd/tests/reader/{settings,scripts,paths,sounds,datafiles,fonts}.test.ts` ✓ (6 files)

**Commits — verified in git log:**
- `8f04f9b` (Task 1 RED settings/scripts/paths) ✓
- `9d326f2` (Task 1 GREEN settings/scripts/paths) ✓
- `f173d82` (Task 2 RED sounds/datafiles/fonts) ✓
- `bd03c3f` (Task 2 GREEN sounds/datafiles/fonts) ✓

**Verification gates — all green:**
- `npx tsc --noEmit` exits 0 ✓
- `npx vitest run` reports 51/51 passing (32 plan-01 + 19 plan-02 = 51) ✓
- `grep "ver >= 800" src/reader/*.ts` returns 6 files (every reader implements timestamp skip) ✓
- `grep "r: BinaryReader, ver: number" src/reader/*.ts` returns 6 hits (one per reader) ✓
- `grep "Buffer\.from" src/reader/{sounds,datafiles,fonts}.ts` returns 0 hits (all reads through BinaryReader) ✓

## TDD Gate Compliance

This is a `type: execute` plan, not `type: tdd` — but each task within it followed the RED→GREEN gate independently. Verified in git log:
- Task 1: `8f04f9b` (test, RED) precedes `9d326f2` (feat, GREEN) ✓
- Task 2: `f173d82` (test, RED) precedes `bd03c3f` (feat, GREEN) ✓

Both RED commits added test files that imported not-yet-existing reader modules — verified to fail with `Cannot find module` before the corresponding GREEN feat commit landed.

---
*Phase: 01-extraction*
*Completed: 2026-05-02*
