From 82b3d0d20a491be472bca4a17b579bad3ea9f978 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 01:28:17 +0000 Subject: [PATCH] metatiles + collision: `metatileset`, `room`, `paint_room`, `collides_at` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes §H. 2×2 metatiles and a parallel collision map are now a first-class construct. `metatileset Name { metatiles: [{ id, tiles, collide }, ...] }` declares a library of 2×2 tile bundles. `room Name { metatileset: M, layout: [...] }` lays them out on a 16×15 grid. The compiler expands each room at compile time into: - a 960-byte nametable (`__room_tiles_`) - a 64-byte attribute table (`__room_attrs_`) - a 240-byte collision bitmap (`__room_col_`) `paint_room Name` reuses the vblank-safe `load_background` update machinery for the nametable blit and installs the collision bitmap pointer into `ZP_ROOM_COL_LO`/`ZP_ROOM_COL_HI` (ZP $18/$19). `collides_at(x, y)` JSRs into a small runtime helper that reads `(room_col),Y` with `Y = (y & 0xF0) | (x >> 4)` and returns 0/1. The helper links in only when the `__collides_at_used` marker is emitted, so programs that declare a room but never query it pay zero bytes for the subroutine. `parse_byte_array` grows a `[value; count]` shortcut — 240-entry `layout` arrays are unwieldy to spell out a byte at a time. See `examples/metatiles_demo.ne` for the end-to-end flow: a probe sprite bounces off walls via `collides_at` and lands on the left side of the playfield at frame 180 — direct evidence that the collision query works. Also defers the register-allocator work from §"Code quality / tooling" and documents the audio-goldens constraint in future-work so the next agent sees it. --- docs/future-work.md | 141 ++++++---- examples/README.md | 1 + examples/metatiles_demo.ne | 138 ++++++++++ examples/metatiles_demo.nes | Bin 0 -> 24592 bytes src/analyzer/mod.rs | 167 +++++++++++- src/analyzer/tests.rs | 158 +++++++++++ src/assets/audio.rs | 2 + src/assets/mod.rs | 3 +- src/assets/resolve.rs | 92 +++++++ src/codegen/ir_codegen.rs | 72 ++++- src/ir/lowering.rs | 16 ++ src/ir/mod.rs | 18 ++ src/lexer/mod.rs | 3 + src/lexer/token.rs | 22 ++ src/linker/mod.rs | 49 ++++ src/optimizer/mod.rs | 7 + src/parser/ast.rs | 78 ++++++ src/parser/mod.rs | 251 ++++++++++++++++++ src/pipeline.rs | 4 +- src/runtime/mod.rs | 69 +++++ .../goldens/metatiles_demo.audio.hash | 1 + tests/emulator/goldens/metatiles_demo.png | Bin 0 -> 845 bytes tests/integration_test.rs | 112 +++++++- 23 files changed, 1344 insertions(+), 60 deletions(-) create mode 100644 examples/metatiles_demo.ne create mode 100644 examples/metatiles_demo.nes create mode 100644 tests/emulator/goldens/metatiles_demo.audio.hash create mode 100644 tests/emulator/goldens/metatiles_demo.png diff --git a/docs/future-work.md b/docs/future-work.md index f1d788e..89beb09 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -182,9 +182,39 @@ on the next `wait_frame`. ### Register allocator All IR temps currently spill to a recycled zero-page slot (`$80-$FF`). The -peephole pass mops up the most obvious waste, but a real CFG-aware allocator -that holds short-lived temps in `A`/`X`/`Y` would cut a noticeable number of -LDA/STA pairs. +peephole pass already handles most obvious waste via its copy-propagation, +dead-store, and dead-load passes; a real CFG-aware allocator that holds +short-lived temps in `A`/`X`/`Y` would cut more LDA/STA pairs, but there's +a subtle constraint worth recording before anyone tackles it: + +**Any cycle-saving optimization shifts audio timing.** The audio output is +a deterministic function of the byte stream — frame counters advance at +fixed NMI offsets, and the SFX/music driver reads those counters on every +tick. An optimization that shortens the main-loop pass between two `wait_frame` +calls changes how many poll iterations land before the next NMI, which +shifts the exact sample boundary of any one-shot SFX trigger relative to +the captured audio. The emulator harness (`tests/emulator/run_examples.mjs`) +captures a sample-exact audio hash at frame 180, so **any such change flips +the audio goldens of every audio-emitting example**. The visual PNG goldens +are stable — PPU writes land in the same vblank — but the audio churn is +real. + +A workable register allocator therefore needs one of: + +1. An acceptance that every code-density improvement comes with a + re-baselined audio golden set, explained in the commit message. +2. A pass that saves ROM bytes but preserves cycle counts (hard on 6502 — + most short-form ops cost the same cycles as their long forms). +3. An opt-in flag (`--O2`) so default builds stay cycle-identical and only + size-hungry users pay the churn. + +Exploration during the §H-priorities branch confirmed (1): a single +`remove_dead_loads` extension that stepped past `LDX`/`LDY` (which don't +clobber A) dropped one LDA per `draw` statement — but flipped audio +goldens for audio_demo, friendly_assets, noise_triangle_sfx, platformer, +pong, and war. The PNG goldens were unchanged. The work was reverted to +keep the §A + §H commits churn-free; see the commit log on the +`claude/prioritize-allocator-signedness` branch. ### State-local memory overlay follow-ups @@ -248,22 +278,16 @@ into the top of the file with a "ships today" note. ### A. Numeric types beyond `u8 / i8 / u16 / i16 / bool` -`i16` ships today; see `examples/i16_demo.ne`. Two known limitations -match the existing `i8` behaviour and will be tackled together when -proper signedness tracking lands in the IR: +`i16` ships today; see `examples/i16_demo.ne` and `examples/signed_compare.ne`. +Signed ordering comparisons and narrow-to-wide sign-extension both ship +too — `Cmp{Lt,Gt,LtEq,GtEq}` and their 16-bit siblings carry a +`Signedness` field that drives the canonical `CMP / SBC / BVC / EOR #$80` +overflow-correction idiom in `gen_cmp_signed_set_n`, and `widen()` emits +`IrOp::SignExtend` for signed narrow values instead of the zero-extended +`LoadImm 0` it used to. Casts to `u8`/`u16` strip the signed flag so +explicit `as` opt-outs stay unsigned. -- **Comparisons are unsigned.** `CmpLt16`/`CmpGt16` use `BCC`/`BCS`, - so `i16` compares against negative values give wrong results. The - fix is a `Signedness` field on `Cmp16Kind` (and `CmpKind` for - `i8`) plus signed branch lowering — `BMI`/`BPL` after an - XOR-on-sign-bit prologue. -- **Narrow-to-wide widening zero-extends.** Assigning a runtime - `i8` expression to an `i16` does not sign-extend the high byte. - Negative literals are folded to the correct wide form by the - lowerer's existing constant-fold path; only runtime expressions - hit the bug. - -Lower-priority numeric follow-ups: +Lower-priority numeric follow-up: - **`u32` / `i32`.** Realistically needed only for score totals and frame counters. A synthesizable pair of 16-bit halves is usually @@ -369,36 +393,44 @@ Still TODO: ### H. Metatiles + collision as a first-class construct -cc65/nesdoug treats 2×2 metatiles + a parallel collision map as -the core room format. `docs/future-work.md` mentions "tilemap -collision queries"; raise the scope to a single cohesive feature: +`metatileset Name { metatiles: [...] }` and `room Name { metatileset: +..., layout: [...] }` ship today — see `examples/metatiles_demo.ne`. +Each metatile bundles four CHR tile indices (TL / TR / BL / BR) plus +a `collide: true|false` flag; rooms lay them out as a 16×15 grid +that the compiler expands at compile time into a 32×30 nametable, +a 64-byte attribute table, and a 240-byte collision bitmap. All +three blobs land as PRG ROM data blocks with per-room labels +(`__room_tiles_N`, `__room_attrs_N`, `__room_col_N`). -``` -metatileset DirtWorld { - source: @tiles("dirt.chr"), - metatiles: [ - { id: 0, tiles: [0, 1, 16, 17], collide: false }, - { id: 1, tiles: [2, 3, 18, 19], collide: true }, - ... - ], -} +`paint_room Name` reuses the existing `load_background` vblank-safe +update machinery against the room's tile/attribute blobs, and +additionally installs the room's collision bitmap pointer into +`ZP_ROOM_COL_LO` / `ZP_ROOM_COL_HI` (ZP `$18`/`$19`). +`collides_at(x: u8, y: u8) -> bool` JSRs into a small runtime +helper that reads `(room_col),Y` where `Y = (y & 0xF0) | (x >> 4)` +and returns the 0/1 bit directly. The helper is gated on a +`__collides_at_used` marker, so a program that declares a room but +never queries it pays zero bytes for the subroutine. -room Level1 { - metatileset: DirtWorld, - layout: @room("level1.nxt"), // NEXXT exporter format -} +**Still TODO.** -on_frame { - if collides_at(hero.x, hero.y) { - ... - } -} -``` - -The compiler would expand each `room` into a packed `[(metatile_id -<< 4 | collision_bits), ...]` blob in PRG ROM, emit a -`collides_at(x: u16, y: u16) -> bool` helper, and stream the -expanded tiles into the VRAM update buffer on a `paint_room()` call. +- **NEXXT file import (`@room("file.nxt")`).** Today only inline + `layout: [...]` (or `[value; count]` for repeated fills) is + supported. The NEXXT exporter's `.nxt` format would let users + author rooms in a dedicated editor. +- **Per-quadrant palette hints.** The generated attribute table + is currently all zeros (sub-palette 0 for every quadrant). A + `palette: 0..3` field on each metatile entry could drive the + attribute table expansion. +- **`collides_at` against u16 coordinates.** The intrinsic takes + u8 x/y today, which covers the full 256×240 visible area but + can't address scrolling playfields bigger than one nametable. +- **Multi-room streaming.** The ZP pointer model already supports + multiple rooms (each `paint_room` rewrites the collision + pointer), but the nametable blit still uses the + nametable-0-only `load_background` path. Streaming into a VRAM + update buffer would let rooms swap without the vblank-budget + risk. ### I. RLE + LZ4 nametable decompression @@ -568,17 +600,18 @@ to a specific bank to avoid bank-switch cost on a hot path. Remaining gap items in order of user value: -1. Register allocator (existing section) — compounding size win. -2. Signedness on Cmp16/Cmp ops (§A follow-up) — closes the i16 - correctness gap. -3. Metatiles + collision (§H) — closes several items at once. -4. Inline-asm format specifiers + directive list (§D follow-ups). -5. VRAM buffer follow-ups (§G) — vertical writes, array copy, +1. Register allocator (existing section) — compounding size win, + but with the audio-goldens caveat spelled out in that section. +2. NEXXT `@room(...)` import + per-quadrant palette hints + (§H follow-ups) — most impactful extensions on top of the + shipped metatiles base. +3. Inline-asm format specifiers + directive list (§D follow-ups). +4. VRAM buffer follow-ups (§G) — vertical writes, array copy, overflow detection. -6. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — +5. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — turns NEScript into a general-purpose NES language. -7. UNROM-512 + MMC5 (§V) — ecosystem fit. -8. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P). +6. UNROM-512 + MMC5 (§V) — ecosystem fit. +7. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P). --- diff --git a/examples/README.md b/examples/README.md index 7641d8b..0fd74ad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -52,6 +52,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. | | `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10` → `$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. | | `signed_compare.ne` | signed `<` / `<=` / `>` / `>=` on `i8` and `i16` | Bounces a marker between X = 32 and X = 224 driven by signed `i16` compares against negative deltas, plus four pip sprites at the top of the screen that gate on directly-negative compares (`i8_neg < 0`, `i16_minus_one < i16_one`, etc.). The signed lowering uses the canonical `CMP / SBC / BVC / EOR #$80` overflow-correction idiom in `gen_cmp_signed_set_n` so the N flag reflects the true sign of the difference. The fourth pip is intentionally dark — it would only light if the lowering fell back to unsigned semantics. The companion integration tests `signed_i16_lt_emits_overflow_corrected_branch` and `signed_i8_lt_emits_overflow_corrected_branch` enforce the asm shape. | +| `metatiles_demo.ne` | `metatileset`, `room`, `paint_room`, `collides_at(x, y)` | 2×2 metatile level format plus a runtime collision query. The `metatileset Blocks` declaration carries two metatile IDs (floor, wall); the `room Dungeon` lays them out as a 16×15 grid the compiler expands to a 32×30 nametable + 240-byte collision bitmap at compile time. `paint_room Dungeon` reuses the existing `load_background` vblank-safe blit machinery and additionally installs the room's collision-bitmap pointer. A probe sprite walks right at 2 px/frame, bounces off the right wall when `collides_at(probe_x + 8, probe_y)` returns `true`, and is back on the left side of the playfield by the golden frame — direct evidence that the collision query works. | | `sram_demo.ne` | `save { var ... }` | Battery-backed save block. The analyzer allocates `high_score` and `coins` at `$6000+` (cartridge SRAM window) instead of main RAM, and the linker flips iNES header byte-6 bit-1 so emulators (FCEUX, Mesen, Nestopia) load and persist the region from a `.sav` file alongside the ROM. SRAM is uninitialized at first power-on; production games should reserve a magic-byte sentinel and validate it before trusting the rest of the data — the compiler doesn't auto-initialize and emits W0111 if you try. | | `vram_buffer_demo.ne` | `nt_set`, `nt_attr`, `nt_fill_h` | Minimal VRAM update buffer exercise — three single-tile writes, a 16-tile horizontal fill, and an attribute write firing every frame. Useful as a test case; see `hud_demo.ne` for a realistic usage pattern. | | `hud_demo.ne` | VRAM buffer driving a classic status bar | A bouncing ball playfield with a HUD across the top: a 5-cell lives indicator that ticks down once per second via `nt_fill_h`, a score counter at the right edge that bumps on every wall hit via `nt_set`, and a one-shot `nt_attr` call at startup that flips the top-left metatile group to a red "UI chrome" palette. Shadow-comparing `score` / `lives` to their `last_*` copies keeps the buffer empty on the ~58-of-60 frames when nothing changed — per-frame cost scales with what actually moved. This is the pattern every nesdoug scoreboard / dialog box / destroyed-metatile animation is built on. | diff --git a/examples/metatiles_demo.ne b/examples/metatiles_demo.ne new file mode 100644 index 0000000..68e57fa --- /dev/null +++ b/examples/metatiles_demo.ne @@ -0,0 +1,138 @@ +// Metatiles + collision demo — shows the `metatileset` / +// `room` declarations, `paint_room` at reset, and +// `collides_at(x, y)` as a runtime query that actually +// changes observable behaviour. +// +// What the program does: +// +// - Declares a `metatileset Blocks` with two 2×2 metatiles: +// `id 0` = floor (CHR tile 0 everywhere, non-colliding) +// `id 1` = wall (CHR tile 0 everywhere, colliding) +// +// Both metatiles use tile 0 in CHR (the built-in smiley) so +// we don't need a sprite declaration just to author tile +// data. The palette swap below is what visually distinguishes +// floor from wall. +// +// - Declares a `room Dungeon` whose 16×15 layout frames the +// playfield with wall metatiles and leaves the interior as +// floor. `paint_room Dungeon` at reset blits the expanded +// 32×30 nametable into NT 0 and installs the room's +// collision bitmap pointer so `collides_at` can answer +// queries against this room. +// +// - A probe sprite marches right along row 9 starting at x=120. +// Every frame the probe advances two pixels. Before drawing +// the sprite we query `collides_at(probe_x + 8, probe_y)` — +// the +8 puts the test point near the sprite's right edge. +// When the query returns true (we've hit the right wall) +// we flip `dx`, so the probe bounces back. +// +// With dx=2 and start x=120, the right-edge hit fires around +// frame 56 (probe_x reaches 232). The probe then bounces +// back at -2 per frame, so by frame 180 (the harness golden +// frame) it's well inside the playfield. A regression that +// silently returned 0 from `collides_at` would leave the +// probe stuck against the wall or wrapping the u8 x +// coordinate — either way, the committed golden wouldn't +// match. +// +// Build: cargo run --release -- build examples/metatiles_demo.ne + +game "Metatiles Demo" { + mapper: NROM +} + +metatileset Blocks { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + { id: 1, tiles: [0, 0, 0, 0], collide: true }, + ], +} + +// 16×15 grid. `1` = wall (colliding), `0` = floor (free). +// +// ┌────────────────┐ row 0 +// │wwwwwwwwwwwwwwww│ row 1-12 frame walls on the edges +// │w..............w│ and leave the interior as floor +// │w..............w│ tiles. Row 9 (the middle-ish) +// │w..............w│ is where the probe walks. +// │w..............w│ +// │w..............w│ +// │w..............w│ +// │w..............w│ +// │w..............w│ +// │w..............w│ +// │w..............w│ +// │wwwwwwwwwwwwwwww│ +room Dungeon { + metatileset: Blocks, + layout: [ + // row 0 — top wall + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + // rows 1..13 — side walls bracket 14 floor cells + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + // row 14 — bottom wall + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + ], +} + +var probe_x: u8 = 120 +var probe_y: u8 = 144 // roughly row 9 of the metatile grid +var dx: i8 = 2 +var painted: u8 = 0 + +on frame { + // Paint the room once on the first frame. Doing this inside + // `on frame` (rather than an `on enter` handler) keeps the + // example minimal and avoids a separate state. + if painted == 0 { + paint_room Dungeon + painted = 1 + } + + // Move the probe two pixels per frame in whichever direction + // `dx` points. Two explicit branches because i8 runtime + // expressions don't compose directly with u8 assignment — + // keeping the sign logic out of the IR is cleaner than + // rigging up a cast chain. + if dx == 2 { + probe_x += 2 + } + if dx == -2 { + probe_x -= 2 + } + + // Check the pixel just ahead of the probe's facing side. + // The probe is 8 pixels wide; testing at `probe_x + 8` is + // the right-edge probe and at `probe_x - 1` is the + // left-edge. Combined with the sprite width this means the + // probe flips direction one pixel before it would visually + // overlap the wall. + if dx == 2 { + if collides_at(probe_x + 8, probe_y) { + dx = -2 + } + } + if dx == -2 { + if collides_at(probe_x - 1, probe_y) { + dx = 2 + } + } + + draw Probe at: (probe_x, probe_y) +} + +start Main diff --git a/examples/metatiles_demo.nes b/examples/metatiles_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..b129044a52fb059ba53391803e1ea346a25283b5 GIT binary patch literal 24592 zcmeI(&ubGw6bJA(v-#0@*`y?CvPobS5mG#e2#N%{6qG2%i+9mm@*nh6nv%H`Ry|20 z1|&EoBZ%}bFga9>7jyI~iqzYJP!CGu+l?)z9z7MmFJYOq-xfjwkOMb%T+WdH_q!}vfDeXu<>AoGf<9(Cn6V0k}%tKW&Qk4y< zin25XKY2vj8c|@aJR0;UI&F_*{GBr?U5PTE2IjrWa@Cx9pz>x{E$B>2stkWx;91cK zRng^<mTtRGhCz&Ll)2XW-?i3%*SZ(hDulHJA6HWC?Y3@B_wrSB=~+sbI-y}l z$c~iS=~(hhYWm_!*Eg#{zj2d8#kS(H{!ZbpO=;IoiuV$~Lz)6qP8_LA)^UbW}X zTs-<-F@OLB{yPD2JZ1&{{N)cbCvSf2e)Y}zR$a@ww(8ttEZ0as>*-8zrz(QtO!wig zGk@51bfzkU%VF9*Y)-uxFUUpRgjl?%nt^=kig` AnalysisResult { for decl in &program.backgrounds { background_names.insert(decl.name.clone()); } + let mut room_names = HashSet::new(); + for decl in &program.rooms { + room_names.insert(decl.name.clone()); + } // Programs that use palette or background declarations need 7 // bytes of zero page for the vblank-safe update handshake // (`$11` flags + 2 × 3 pointer slots). Bump the user zero-page @@ -100,8 +111,18 @@ pub fn analyze(program: &Program) -> AnalysisResult { // examples) keep the legacy layout because their fixed-bank // user code never invokes `__bank_select`. let needs_ppu_update_slots = !program.palettes.is_empty() || !program.backgrounds.is_empty(); + // Rooms reserve two additional ZP bytes at `$18-$19` for the + // `paint_room` → `collides_at` room-pointer handoff, on top of + // whatever PPU-update slots are already reserved. Programs that + // use palettes/backgrounds AND rooms pay both bumps; programs + // with just rooms pay only the room bump (the PPU-update slot + // region is a prefix of the same block so rooms imply PPU + // updates via `paint_room`). + let needs_room_slots = !program.rooms.is_empty(); let needs_bank_current_slot = program.functions.iter().any(|f| f.bank.is_some()); - let next_zp_addr = if needs_ppu_update_slots { + let next_zp_addr = if needs_room_slots { + 0x1A + } else if needs_ppu_update_slots { 0x18 } else if needs_bank_current_slot { 0x11 @@ -124,6 +145,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { music_names, palette_names, background_names, + room_names, next_ram_addr: initial_ram_addr, next_zp_addr, call_graph: HashMap::new(), @@ -177,6 +199,9 @@ struct Analyzer { /// Set of background names declared in the program. Used to /// validate `load_background Name` targets. background_names: HashSet, + /// Set of room names declared in the program. Used to validate + /// `paint_room Name` targets. + room_names: HashSet, next_ram_addr: u16, next_zp_addr: u8, /// Bump-pointer for save-block (`save { var ... }`) variables. @@ -562,6 +587,122 @@ impl Analyzer { } } + // Validate metatileset declarations: each tile array must + // have exactly 4 entries (TL/TR/BL/BR), entry IDs must + // match their position in the array, names are unique + // across metatilesets, and the count is capped at 256 so + // a metatile ID still fits in a u8. The compile-time + // expansion in `IrCodeGen::generate` reads from these + // tables, so anything that gets past the analyzer here + // produces a corrupt nametable rather than a panic. + let mut seen_metatilesets: HashSet = HashSet::new(); + for mts in &program.metatilesets { + if !seen_metatilesets.insert(mts.name.clone()) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate metatileset '{}'", mts.name), + mts.span, + )); + } + if mts.metatiles.is_empty() { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metatileset '{}' is empty — declare at least one metatile", + mts.name + ), + mts.span, + )); + } + if mts.metatiles.len() > 256 { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metatileset '{}' has {} metatiles; the limit is 256 (one byte of ID space)", + mts.name, + mts.metatiles.len() + ), + mts.span, + )); + } + for (i, mt) in mts.metatiles.iter().enumerate() { + if usize::from(mt.id) != i { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metatile entry #{i} in metatileset '{}' has id={}; entries \ + must be declared in ascending order with consecutive IDs starting \ + at 0", + mts.name, mt.id + ), + mt.span, + )); + } + } + } + + // Validate room declarations: must reference a known + // metatileset, the layout must be exactly 240 entries + // (16 wide × 15 tall — covers a 32×30 nametable when + // expanded to 2×2 tiles), and every layout byte must be + // a valid metatile ID for the referenced set. + let mts_by_name: std::collections::HashMap<&str, &MetatilesetDecl> = program + .metatilesets + .iter() + .map(|m| (m.name.as_str(), m)) + .collect(); + let mut seen_rooms: HashSet = HashSet::new(); + for room in &program.rooms { + if !seen_rooms.insert(room.name.clone()) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate room '{}'", room.name), + room.span, + )); + } + if room.layout.len() != ROOM_CELL_COUNT { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "room '{}' layout has {} entries; expected exactly {ROOM_CELL_COUNT} \ + (a {ROOM_GRID_WIDTH}×{ROOM_GRID_HEIGHT} grid of metatile IDs)", + room.name, + room.layout.len() + ), + room.span, + )); + } + match mts_by_name.get(room.metatileset.as_str()) { + None => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "room '{}' references unknown metatileset '{}'", + room.name, room.metatileset + ), + room.span, + )); + } + Some(mts) => { + let max_id = mts.metatiles.len().saturating_sub(1); + for (i, &b) in room.layout.iter().enumerate() { + if usize::from(b) > max_id { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "room '{}' layout cell {i} = {b} is out of range for \ + metatileset '{}' (valid IDs are 0..={max_id})", + room.name, room.metatileset + ), + room.span, + )); + break; + } + } + } + } + } + // Register functions as symbols for fun in &program.functions { self.register_fun(fun); @@ -2299,6 +2440,15 @@ impl Analyzer { )); } } + Statement::PaintRoom(name, span) => { + if !self.room_names.contains(name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0502, + format!("unknown room '{name}'"), + *span, + )); + } + } Statement::DebugLog(args, _) => { for arg in args { self.walk_expr_reads(arg); @@ -2467,6 +2617,7 @@ impl Analyzer { // PRNG intrinsics: rand8 returns u8, rand16 returns u16. "rand8" => Some(NesType::U8), "rand16" => Some(NesType::U16), + "collides_at" => Some(NesType::Bool), _ => Some(NesType::U8), // Simplified for M1 }, Expr::ArrayIndex(name, _, _) => { @@ -2696,7 +2847,8 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec) { | Statement::StartMusic(_, _) | Statement::StopMusic(_) | Statement::SetPalette(_, _) - | Statement::LoadBackground(_, _) => {} + | Statement::LoadBackground(_, _) + | Statement::PaintRoom(_, _) => {} } } @@ -2775,6 +2927,7 @@ fn is_intrinsic(name: &str) -> bool { | "nt_set" | "nt_attr" | "nt_fill_h" + | "collides_at" ) } @@ -2896,6 +3049,16 @@ impl Analyzer { span, )); } + "collides_at" if args.len() != 2 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`collides_at` takes exactly 2 arguments (x: u8, y: u8), got {}", + args.len() + ), + span, + )); + } _ => {} } } diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 98f5a6a..77b107f 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -2707,3 +2707,161 @@ fn analyze_inline_fun_with_loop_body_trips_w0110() { result.diagnostics ); } + +// ── Metatiles + rooms (§H) ── + +#[test] +fn analyze_room_layout_wrong_size_errors() { + // A room's layout must be exactly 240 entries (16×15). A short + // layout is a user error caught by the analyzer; silently + // padding with zeros would produce a nametable with garbage in + // the uncovered rows and silently misalign `collides_at` + // queries. + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + ], + } + room Level1 { + metatileset: MTS, + layout: [0, 0, 0], + } + on frame { } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for short room layout, got {errors:?}" + ); +} + +#[test] +fn analyze_room_unknown_metatileset_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + room Level1 { + metatileset: Missing, + layout: [0; 240], + } + on frame { } + start Main + "#, + ); + // We accept either the "unknown metatileset" error or (less + // specific but equally correct) a layout validation error that + // fires first. What matters is we don't silently accept the + // program. + assert!( + !errors.is_empty(), + "expected at least one diagnostic for unknown metatileset" + ); +} + +#[test] +fn analyze_paint_room_unknown_room_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + on frame { paint_room Ghost } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0502), + "expected E0502 for paint_room of unknown room, got {errors:?}" + ); +} + +#[test] +fn analyze_room_with_valid_metatileset_accepts() { + // Full valid program — metatileset declared, room layout + // covers all 240 cells, paint_room + collides_at both used. + // The parser's `[expr; N]` literal is supported for byte + // arrays; we test that shortcut path here too. + analyze_ok( + r#" + game "Test" { mapper: NROM } + metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + { id: 1, tiles: [1, 1, 1, 1], collide: true }, + ], + } + room Level1 { + metatileset: MTS, + layout: [0; 240], + } + on frame { + paint_room Level1 + if collides_at(64, 64) { } + } + start Main + "#, + ); +} + +#[test] +fn analyze_metatile_wrong_tile_count_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0], collide: false }, + ], + } + on frame { } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for 3-tile metatile entry, got {errors:?}" + ); +} + +#[test] +fn analyze_room_layout_id_out_of_range_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + ], + } + room Level1 { + metatileset: MTS, + layout: [7; 240], + } + on frame { } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for out-of-range metatile id, got {errors:?}" + ); +} + +#[test] +fn analyze_collides_at_wrong_arg_count_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + on frame { + if collides_at(64) { } + } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0203), + "expected E0203 for collides_at arity mismatch, got {errors:?}" + ); +} diff --git a/src/assets/audio.rs b/src/assets/audio.rs index 4abb89a..9106271 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -778,6 +778,8 @@ mod tests { palettes: Vec::new(), backgrounds: Vec::new(), metasprites: Vec::new(), + metatilesets: Vec::new(), + rooms: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/assets/mod.rs b/src/assets/mod.rs index 19ead0e..a2741f6 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -12,5 +12,6 @@ pub use audio::{ pub use chr::{png_to_chr, png_to_nametable, png_to_nametable_with_chr}; pub use palette::{color_name_to_index, nearest_nes_color, png_to_palette, NES_COLORS}; pub use resolve::{ - resolve_backgrounds, resolve_palettes, resolve_sprites, BackgroundData, PaletteData, + resolve_backgrounds, resolve_palettes, resolve_rooms, resolve_sprites, BackgroundData, + PaletteData, RoomData, }; diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 8e5cead..ac07396 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -60,6 +60,49 @@ impl BackgroundData { } } +/// Resolved level data for a `room Name { ... }` declaration. The +/// compiler expands each room (16×15 metatile grid) at compile time +/// into three fixed-size blobs that the linker embeds in PRG ROM: +/// +/// - `tiles` (960 bytes): the 32×30 nametable the room paints into. +/// Each of the 240 metatile cells produces 2×2 CHR tile indices, +/// row-major. +/// - `attrs` (64 bytes): the 8×8 attribute table. v1 writes zeros +/// (sub-palette 0 for every quadrant); a future revision can +/// derive this from per-metatile palette hints. +/// - `collision` (240 bytes): one byte per metatile, mirroring the +/// `layout` array but carrying the metatileset's `collide` flag +/// instead of the metatile ID. `collides_at(x, y)` indexes into +/// this by `(y / 16) * 16 + (x / 16)`. +/// +/// Labels used downstream: `__room_tiles_`, `__room_attrs_`, +/// `__room_col_`. `paint_room ` queues the first two +/// through the same vblank-safe update machinery as `load_background`, +/// and installs the third's address into the room-pointer ZP slots +/// so subsequent `collides_at` queries read from this room's map. +#[derive(Debug, Clone)] +pub struct RoomData { + pub name: String, + pub tiles: [u8; 960], + pub attrs: [u8; 64], + pub collision: [u8; 240], +} + +impl RoomData { + #[must_use] + pub fn tiles_label(&self) -> String { + format!("__room_tiles_{}", self.name) + } + #[must_use] + pub fn attrs_label(&self) -> String { + format!("__room_attrs_{}", self.name) + } + #[must_use] + pub fn collision_label(&self) -> String { + format!("__room_col_{}", self.name) + } +} + /// Resolve sprite declarations in a program into concrete CHR byte blobs and /// assign each one a tile index in CHR ROM. /// @@ -225,6 +268,51 @@ pub fn resolve_backgrounds( Ok(out) } +/// Resolve every `room Name { ... }` declaration in `program` into +/// expanded [`RoomData`] blobs. The analyzer has already validated +/// layout length, metatile IDs, and metatileset references, so this +/// is a straightforward 16×15 → 32×30 unpack plus a parallel +/// collision-byte copy. +pub fn resolve_rooms(program: &Program) -> Vec { + let mut out = Vec::with_capacity(program.rooms.len()); + let mts_by_name: std::collections::HashMap<&str, &crate::parser::ast::MetatilesetDecl> = + program + .metatilesets + .iter() + .map(|m| (m.name.as_str(), m)) + .collect(); + for room in &program.rooms { + let mut tiles = [0u8; 960]; + let mut collision = [0u8; 240]; + if let Some(mts) = mts_by_name.get(room.metatileset.as_str()) { + for (cell_idx, &mt_id) in room.layout.iter().enumerate().take(240) { + let mt_x = cell_idx % 16; + let mt_y = cell_idx / 16; + let entry = &mts.metatiles[usize::from(mt_id)]; + // Expand 2×2 metatile into its four nametable cells. + // Each nametable row is 32 cells wide; each metatile + // row is 15 metatiles tall × 2 = 30 rows (a full + // playfield height). + let tl_x = mt_x * 2; + let tl_y = mt_y * 2; + let tl_idx = tl_y * 32 + tl_x; + tiles[tl_idx] = entry.tiles[0]; // TL + tiles[tl_idx + 1] = entry.tiles[1]; // TR + tiles[tl_idx + 32] = entry.tiles[2]; // BL + tiles[tl_idx + 33] = entry.tiles[3]; // BR + collision[cell_idx] = u8::from(entry.collide); + } + } + out.push(RoomData { + name: room.name.clone(), + tiles, + attrs: [0u8; 64], + collision, + }); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -253,6 +341,8 @@ mod tests { palettes: Vec::new(), backgrounds: Vec::new(), metasprites: Vec::new(), + metatilesets: Vec::new(), + rooms: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), @@ -335,6 +425,8 @@ mod tests { palettes: Vec::new(), backgrounds: Vec::new(), metasprites: Vec::new(), + metatilesets: Vec::new(), + rooms: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index f9913b4..e969692 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -37,7 +37,8 @@ use crate::runtime::{ ZP_MUSIC_BASE_HI, ZP_MUSIC_BASE_LO, ZP_MUSIC_COUNTER, ZP_MUSIC_PTR_HI, ZP_MUSIC_PTR_LO, ZP_MUSIC_STATE, ZP_OAM_CURSOR, ZP_PENDING_BG_ATTRS_HI, ZP_PENDING_BG_ATTRS_LO, ZP_PENDING_BG_TILES_HI, ZP_PENDING_BG_TILES_LO, ZP_PENDING_PALETTE_HI, ZP_PENDING_PALETTE_LO, - ZP_PPU_UPDATE_FLAGS, ZP_SFX_COUNTER, ZP_SFX_PTR_HI, ZP_SFX_PTR_LO, + ZP_PPU_UPDATE_FLAGS, ZP_ROOM_COL_HI, ZP_ROOM_COL_LO, ZP_SFX_COUNTER, ZP_SFX_PTR_HI, + ZP_SFX_PTR_LO, }; /// Base zero-page address for IR temp slots. @@ -244,6 +245,11 @@ pub struct IrCodeGen<'a> { /// from NMI, and reserve the 256-byte buffer at `$0400-$04FF` /// from the analyzer's RAM allocator. vram_buf_used: bool, + /// Set to true the first time `gen_collides_at` runs. Drives + /// the `__collides_at_used` marker label emission in + /// `emit_trailing_markers`; the linker uses it to decide + /// whether to splice in `runtime::gen_collides_at`. + collides_at_used: bool, /// Source-location markers produced from [`IrOp::SourceLoc`]. /// Each entry is a `(label_name, span)` pair — the codegen /// emits a unique label-definition pseudo-op at the current @@ -475,6 +481,7 @@ impl<'a> IrCodeGen<'a> { fade_used: false, edge_input_used: false, vram_buf_used: false, + collides_at_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, @@ -1998,6 +2005,8 @@ impl<'a> IrCodeGen<'a> { IrOp::StopMusic => self.gen_stop_music(), IrOp::SetPalette(name) => self.gen_set_palette(name), IrOp::LoadBackground(name) => self.gen_load_background(name), + IrOp::PaintRoom(name) => self.gen_paint_room(name), + IrOp::CollidesAt { dest, x, y } => self.gen_collides_at(*dest, *x, *y), IrOp::LoadVarHi(dest, var) => { let base = self.var_addr(*var); let addr = base.wrapping_add(1); @@ -2565,6 +2574,60 @@ impl<'a> IrCodeGen<'a> { self.emit(STA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)); } + /// Emit `paint_room Name`. Two things happen per call: + /// + /// 1. Queue the room's expanded nametable for a vblank-safe + /// copy into nametable 0 — identical to the + /// `load_background` dance, just pointing at the room's + /// `__room_tiles_` / `__room_attrs_` labels + /// instead of the background equivalents. + /// 2. Install the room's 240-byte collision bitmap address + /// into `ZP_ROOM_COL_LO` / `ZP_ROOM_COL_HI` so subsequent + /// `collides_at(x, y)` calls read from this room's map. + /// + /// Programs that paint only one room never rewrite the + /// collision pointer after the first call; programs that + /// swap rooms see `collides_at` follow the last `paint_room`. + fn gen_paint_room(&mut self, name: &str) { + self.emit_ppu_update_marker(); + let tiles_label = format!("__room_tiles_{name}"); + let attrs_label = format!("__room_attrs_{name}"); + let col_label = format!("__room_col_{name}"); + self.emit(LDA, AM::SymbolLo(tiles_label.clone())); + self.emit(STA, AM::ZeroPage(ZP_PENDING_BG_TILES_LO)); + self.emit(LDA, AM::SymbolHi(tiles_label)); + self.emit(STA, AM::ZeroPage(ZP_PENDING_BG_TILES_HI)); + self.emit(LDA, AM::SymbolLo(attrs_label.clone())); + self.emit(STA, AM::ZeroPage(ZP_PENDING_BG_ATTRS_LO)); + self.emit(LDA, AM::SymbolHi(attrs_label)); + self.emit(STA, AM::ZeroPage(ZP_PENDING_BG_ATTRS_HI)); + self.emit(LDA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)); + self.emit(ORA, AM::Immediate(0x02)); + self.emit(STA, AM::ZeroPage(ZP_PPU_UPDATE_FLAGS)); + // Collision pointer install. + self.emit(LDA, AM::SymbolLo(col_label.clone())); + self.emit(STA, AM::ZeroPage(ZP_ROOM_COL_LO)); + self.emit(LDA, AM::SymbolHi(col_label)); + self.emit(STA, AM::ZeroPage(ZP_ROOM_COL_HI)); + } + + /// Emit a `collides_at(x, y)` call. Loads x into A and y into + /// X (matching `__collides_at`'s register contract), JSRs the + /// helper, and stores the 0/1 result in `dest`. Also flags + /// `__collides_at_used` so the linker splices the helper in. + fn gen_collides_at(&mut self, dest: IrTemp, x: IrTemp, y: IrTemp) { + self.collides_at_used = true; + // Load x into A, y into X. Do this from slot addresses + // directly so we don't need to stage through a third + // temp (both arguments are already in the temp region). + let x_addr = self.temp_addr(x); + let y_addr = self.temp_addr(y); + self.emit(LDX, AM::ZeroPage(y_addr)); + self.emit(LDA, AM::ZeroPage(x_addr)); + self.emit(JSR, AM::Label("__collides_at".into())); + self.store_temp(dest); + } + /// Emit the `__ppu_update_used` marker label at most once per /// program. The linker scans for this label to decide whether /// to splice the PPU update helper into NMI. Programs that @@ -2623,6 +2686,9 @@ impl<'a> IrCodeGen<'a> { if self.vram_buf_used { self.emit_label("__vram_buf_used"); } + if self.collides_at_used { + self.emit_label("__collides_at_used"); + } } /// Emit the `__rand_used` marker label at most once per @@ -3371,6 +3437,8 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::CmpGtEq16 { .. } | IrOp::SetPalette(..) | IrOp::LoadBackground(..) + | IrOp::PaintRoom(..) + | IrOp::CollidesAt { .. } | IrOp::PlaySfx(..) | IrOp::StartMusic(..) | IrOp::StopMusic @@ -3647,6 +3715,7 @@ fn op_source_temps(op: &IrOp) -> Vec { | IrOp::Rand16(_, _) | IrOp::SetPalette(_) | IrOp::LoadBackground(_) + | IrOp::PaintRoom(_) | IrOp::SourceLoc(_) => Vec::new(), IrOp::SeedRand(lo, hi) => vec![*lo, *hi], IrOp::SetPaletteBrightness(level) => vec![*level], @@ -3655,6 +3724,7 @@ fn op_source_temps(op: &IrOp) -> Vec { IrOp::NtSet { x, y, tile } => vec![*x, *y, *tile], IrOp::NtAttr { x, y, value } => vec![*x, *y, *value], IrOp::NtFillH { x, y, len, tile } => vec![*x, *y, *len, *tile], + IrOp::CollidesAt { x, y, .. } => vec![*x, *y], } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 7a724cd..b38788b 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -1265,6 +1265,9 @@ impl LoweringContext { Statement::LoadBackground(name, _) => { self.emit(IrOp::LoadBackground(name.clone())); } + Statement::PaintRoom(name, _) => { + self.emit(IrOp::PaintRoom(name.clone())); + } Statement::DebugLog(args, _) => { let temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); self.emit(IrOp::DebugLog(temps)); @@ -1905,6 +1908,18 @@ impl LoweringContext { self.make_wide(lo, hi); return lo; } + // `collides_at(x, y)` — a 2-arg bool-returning + // builtin. Both args are u8 screen coordinates; the + // result is 0 / 1. Codegen emits a JSR to a shared + // runtime helper that walks the current room's + // collision bitmap. + if name == "collides_at" && args.len() == 2 { + let x = self.lower_expr(&args[0]); + let y = self.lower_expr(&args[1]); + let dest = self.fresh_temp(); + self.emit(IrOp::CollidesAt { dest, x, y }); + return dest; + } // `inline fun` bodies captured by // `capture_inline_bodies` expand in-place here: // no JSR, no parameter transport, no prologue. @@ -2289,6 +2304,7 @@ fn is_splicable_void_stmt(stmt: &Statement) -> bool { | Statement::Scroll(..) | Statement::SetPalette(..) | Statement::LoadBackground(..) + | Statement::PaintRoom(..) | Statement::WaitFrame(..) | Statement::CycleSprites(..) | Statement::Play(..) diff --git a/src/ir/mod.rs b/src/ir/mod.rs index c2ad57e..83869fe 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -319,6 +319,24 @@ pub enum IrOp { /// label pointers into their ZP slots and sets a pending bit; /// the NMI handler applies the writes at the next vblank. LoadBackground(String), + /// `paint_room Name` — queues the room's compile-time-expanded + /// nametable for vblank-safe blitting (same machinery as + /// `LoadBackground`) AND installs the room's collision bitmap + /// address into `ZP_ROOM_COL_LO` / `ZP_ROOM_COL_HI`. Subsequent + /// `collides_at(x, y)` queries answer against that bitmap. + PaintRoom(String), + /// `collides_at(x, y)` — a boolean query against the currently- + /// painted room's collision bitmap. Zero-argument use + /// (`dest = collides_at(x, y)`) reads the pointed-to bitmap + /// for the metatile covering `(x, y)`; returns 0 (no collision) + /// or 1 (collision). Codegen lowers to a JSR into a shared + /// `__collides_at` runtime helper that's spliced in when the + /// `__collides_at_used` marker is present. + CollidesAt { + dest: IrTemp, + x: IrTemp, + y: IrTemp, + }, // Audio ops — map to the minimal APU driver emitted by the linker. /// `play SfxName` — trigger a one-shot sound effect on pulse 1. diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index dd266fd..ece98ad 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -468,6 +468,9 @@ impl<'a> Lexer<'a> { "sfx" => TokenKind::KwSfx, "music" => TokenKind::KwMusic, "save" => TokenKind::KwSave, + "metatileset" => TokenKind::KwMetatileset, + "room" => TokenKind::KwRoom, + "paint_room" => TokenKind::KwPaintRoom, "draw" => TokenKind::KwDraw, "play" => TokenKind::KwPlay, "stop_music" => TokenKind::KwStopMusic, diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 0bd3a67..60e7e94 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -72,6 +72,25 @@ pub enum TokenKind { KwSfx, KwMusic, KwSave, + /// `metatileset Name { metatiles: [...] }` — a packed library of + /// 2×2 metatile definitions (4 CHR tile indices per metatile, + /// plus a `collide` flag). See `parse_metatileset_decl` in the + /// parser. Paired with `room` for the level-data feature pulled + /// from `docs/future-work.md` §H. + KwMetatileset, + /// `room Name { metatileset: Name, layout: [16x15 ids] }` — a + /// concrete level laid out as a 16×15 grid of metatile IDs from + /// the named metatileset. The compiler expands this into a + /// 32×30 nametable + collision bitmap at compile time so the + /// runtime cost matches the existing `background` path. + KwRoom, + /// `paint_room Name` — the room-flavoured sibling of + /// `load_background`. Queues a nametable update for the next + /// vblank using the room's compile-time-expanded tile grid, and + /// also sets the runtime's "current room collision map" + /// pointer so subsequent `collides_at(x, y)` queries answer + /// against this room. + KwPaintRoom, KwDraw, KwPlay, KwStopMusic, @@ -185,6 +204,9 @@ impl std::fmt::Display for TokenKind { Self::KwSfx => write!(f, "sfx"), Self::KwMusic => write!(f, "music"), Self::KwSave => write!(f, "save"), + Self::KwMetatileset => write!(f, "metatileset"), + Self::KwRoom => write!(f, "room"), + Self::KwPaintRoom => write!(f, "paint_room"), Self::KwDraw => write!(f, "draw"), Self::KwPlay => write!(f, "play"), Self::KwStopMusic => write!(f, "stop_music"), diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 6e538f4..c2e8786 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -50,6 +50,14 @@ pub struct Linker { /// header byte 6 bit 1 is set; emulators that respect it persist /// the `$6000-$7FFF` SRAM region across power cycles. has_battery: bool, + /// Resolved room level data. Populated via [`Linker::with_rooms`] + /// by the CLI / top-level compile. Each entry produces three + /// data blocks in PRG ROM (`__room_tiles_N`, `__room_attrs_N`, + /// `__room_col_N`) that `paint_room` and `collides_at` reference + /// by symbol. Kept as an owned `Vec` so `Linker` can carry the + /// data through the builder chain without threading yet another + /// parameter through every entry point. + rooms: Vec, } /// CHR data for a sprite, placed at a specific tile index in CHR ROM. @@ -197,6 +205,7 @@ impl Linker { mapper: Mapper::NROM, header_format: HeaderFormat::Ines1, has_battery: false, + rooms: Vec::new(), } } @@ -206,9 +215,21 @@ impl Linker { mapper, header_format: HeaderFormat::Ines1, has_battery: false, + rooms: Vec::new(), } } + /// Supply the resolved `room` level data the CLI got back from + /// [`crate::assets::resolve_rooms`]. The linker emits one set of + /// tile / attribute / collision blobs per room and the codegen + /// references them by symbol from `paint_room` / `collides_at` + /// call sites. + #[must_use] + pub fn with_rooms(mut self, rooms: Vec) -> Self { + self.rooms = rooms; + self + } + /// Opt into the NES 2.0 header format for the emitted ROM. /// Chainable builder method — returns `self` so callers can /// write `Linker::with_mapper(m, p).with_header(HeaderFormat::Nes2)`. @@ -618,6 +639,14 @@ impl Linker { all_instructions.extend(runtime::gen_vram_buf_drain()); } + // `__collides_at` helper — spliced in when the codegen emits + // the `__collides_at_used` marker. Programs that declare a + // `room` but never call `collides_at(...)` skip the helper + // entirely. + if has_label(user_code, "__collides_at_used") { + all_instructions.extend(runtime::gen_collides_at()); + } + // Audio subsystem — linked in whenever user code touched // audio (detected via the `__audio_used` marker emitted by // the IR codegen). The driver body, period table, and @@ -702,6 +731,25 @@ impl Linker { bg.attrs.to_vec(), )); } + // Room data — one set of tile / attribute / collision blobs + // per declared `room`. `paint_room Name` references the + // first two through the same vblank update helper as + // backgrounds, and `collides_at` indexes into the third. + // Programs with no rooms emit nothing here. + for room in &self.rooms { + all_instructions.extend(runtime::gen_data_block( + &room.tiles_label(), + room.tiles.to_vec(), + )); + all_instructions.extend(runtime::gen_data_block( + &room.attrs_label(), + room.attrs.to_vec(), + )); + all_instructions.extend(runtime::gen_data_block( + &room.collision_label(), + room.collision.to_vec(), + )); + } // The NMI needs the palette/nametable update helper whenever // the program declared any palette or background, or the @@ -712,6 +760,7 @@ impl Linker { // zero bytes. let has_ppu_updates = !palettes.is_empty() || !backgrounds.is_empty() + || !self.rooms.is_empty() || has_label(user_code, "__ppu_update_used"); // NMI handler diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 622f720..d51b214 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -607,6 +607,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { | IrOp::Rand16(_, _) | IrOp::SetPalette(_) | IrOp::LoadBackground(_) + | IrOp::PaintRoom(_) | IrOp::SourceLoc(_) => {} IrOp::SeedRand(lo, hi) => { used.insert(*lo); @@ -638,6 +639,10 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { used.insert(*len); used.insert(*tile); } + IrOp::CollidesAt { x, y, .. } => { + used.insert(*x); + used.insert(*y); + } } } @@ -672,6 +677,7 @@ fn op_dest(op: &IrOp) -> Option { IrOp::ReadInput(d, _) => Some(*d), IrOp::ReadInputEdge { dest, .. } => Some(*dest), IrOp::Peek(d, _) => Some(*d), + IrOp::CollidesAt { dest, .. } => Some(*dest), // Rand8 / Rand16 have an observable side effect — advancing // the PRNG state — so a statement-level call like // `rand8()` (result discarded) must NOT be dropped by DCE. @@ -705,6 +711,7 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::NtFillH { .. } | IrOp::SetPalette(_) | IrOp::LoadBackground(_) + | IrOp::PaintRoom(_) | IrOp::SourceLoc(_) => None, // 16-bit ops have two destinations; the simple single-dest // DCE below would incorrectly drop a 16-bit op whose low diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 8d3383f..7be1e21 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -23,6 +23,21 @@ pub struct Program { pub palettes: Vec, pub backgrounds: Vec, pub metasprites: Vec, + /// Top-level `metatileset Name { metatiles: [...] }` declarations. + /// Each one is a packed library of 2×2 tile bundles plus a per- + /// metatile collision flag, used by `room` to assemble level + /// data and by `collides_at(x, y)` to answer the runtime + /// "is this pixel inside a solid metatile?" query. See + /// [`MetatilesetDecl`] for the data shape and the §H entry in + /// `docs/future-work.md` for the design. + pub metatilesets: Vec, + /// Top-level `room Name { metatileset: ..., layout: [...] }` + /// declarations. Each room is a 16×15 grid of metatile IDs that + /// the compiler expands into a 32×30 nametable + 64-byte + /// attribute table + 240-byte collision bitmap, all PRG-resident. + /// `paint_room Name` paints the expanded nametable; `collides_at` + /// reads the collision bitmap. + pub rooms: Vec, pub sfx: Vec, pub music: Vec, pub banks: Vec, @@ -60,6 +75,61 @@ pub struct MetaspriteDecl { pub span: Span, } +/// `metatileset Name { metatiles: [...] }` — a packed library of +/// 2×2 metatile definitions. Each metatile bundles four CHR tile +/// indices (top-left, top-right, bottom-left, bottom-right) plus a +/// per-metatile collision bool. The analyzer caps the metatile +/// count at 256 so a metatile ID fits in a single byte. +/// +/// Pairs with [`RoomDecl`] (which references the metatileset by +/// name and lays out rooms as a 16×15 grid of metatile IDs) and the +/// `collides_at(x, y) -> bool` builtin (which reads the per-room +/// collision bitmap derived from this `collide` flag). +#[derive(Debug, Clone)] +pub struct MetatilesetDecl { + pub name: String, + pub metatiles: Vec, + pub span: Span, +} + +/// One entry in a `metatileset { metatiles: [...] }` array. The +/// `id` field is mostly informational — entries are stored in +/// declaration order and looked up by index from `RoomDecl::layout`, +/// so the analyzer rejects out-of-order or duplicate IDs to keep +/// the source readable. +#[derive(Debug, Clone)] +pub struct MetatileEntry { + pub id: u8, + pub tiles: [u8; 4], + pub collide: bool, + pub span: Span, +} + +/// `room Name { metatileset: M, layout: [16x15 metatile ids] }` — a +/// concrete level. The compiler: +/// +/// - validates every `layout` byte against the named metatileset's +/// declared IDs (E0210); +/// - expands the 240 cells into a 32×30 (=960 byte) nametable using +/// each metatile's four tile indices; +/// - emits a 64-byte attribute table (currently all sub-palette 0; +/// a future revision can switch to per-quadrant palette hints); +/// - emits a 30-byte (240-bit) collision bitmap so `collides_at` +/// can answer in two indexed loads + a single shift. +/// +/// At runtime, `paint_room Name` desugars to the existing +/// `load_background` machinery against the synthesized nametable +/// blob, and `collides_at(x, y)` JSRs into a small helper that +/// reads the room's collision bitmap. +#[derive(Debug, Clone)] +pub struct RoomDecl { + pub name: String, + pub metatileset: String, + /// 240 bytes: row-major, row 0 = top of screen, x = column 0..15. + pub layout: Vec, + pub span: Span, +} + /// `enum Name { V1, V2, V3 }` — variants become u8 constants with /// values equal to their declaration order (0, 1, 2, ...). Variant /// names are global: they're flattened into the constants table so @@ -549,6 +619,13 @@ pub enum Statement { /// vblank-safe copy into nametable 0. Lowered to /// [`IrOp::LoadBackground`]. LoadBackground(String, Span), + /// `paint_room Name` — the room-aware sibling of + /// `load_background`. The compiler synthesizes a background- + /// shaped blob for the room at compile time; at runtime, + /// `paint_room` queues that nametable update AND sets the + /// current-room ZP pointer so `collides_at(x, y)` queries hit + /// the right collision bitmap. Lowered to [`IrOp::PaintRoom`]. + PaintRoom(String, Span), /// `set_palette Name` — queue the named palette for a /// vblank-safe copy into `$3F00-$3F1F`. Lowered to /// [`IrOp::SetPalette`]. @@ -594,6 +671,7 @@ impl Statement { | Self::CycleSprites(s) | Self::Call(_, _, s) | Self::LoadBackground(_, s) + | Self::PaintRoom(_, s) | Self::SetPalette(_, s) | Self::Scroll(_, _, s) | Self::DebugLog(_, s) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9824fb3..cd83430 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -130,6 +130,8 @@ impl Parser { let mut palettes = Vec::new(); let mut backgrounds = Vec::new(); let mut metasprites = Vec::new(); + let mut metatilesets = Vec::new(); + let mut rooms = Vec::new(); let mut sfx = Vec::new(); let mut music = Vec::new(); let mut saves: Vec = Vec::new(); @@ -176,6 +178,12 @@ impl Parser { TokenKind::KwBackground => { backgrounds.push(self.parse_background_decl()?); } + TokenKind::KwMetatileset => { + metatilesets.push(self.parse_metatileset_decl()?); + } + TokenKind::KwRoom => { + rooms.push(self.parse_room_decl()?); + } TokenKind::KwSfx => { sfx.push(self.parse_sfx_decl()?); } @@ -288,6 +296,8 @@ impl Parser { palettes, backgrounds, metasprites, + metatilesets, + rooms, sfx, music, banks, @@ -1057,6 +1067,212 @@ impl Parser { }) } + /// Parse `metatileset Name { metatiles: [{ id: N, tiles: [...], + /// collide: true|false }, ...] }`. The body is a single + /// `metatiles:` array of brace-delimited entries; each entry has + /// the three fixed properties `id`, `tiles`, and `collide`. The + /// analyzer enforces that each entry's `tiles:` array is exactly + /// 4 bytes (top-left, top-right, bottom-left, bottom-right) and + /// that the entries' IDs match their position in the array. + fn parse_metatileset_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwMetatileset)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + + let mut metatiles: Option> = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let (key, key_span) = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "metatiles" => { + metatiles = Some(self.parse_metatile_array()?); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown metatileset property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let metatiles = metatiles.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("metatileset '{name}' is missing the required 'metatiles:' array"), + start, + ) + })?; + Ok(MetatilesetDecl { + name, + metatiles, + span: Span::new(start.file_id, start.start, self.current_span().end), + }) + } + + /// Parse the body of a `metatileset` declaration's `metatiles:` + /// array. Each entry is `{ id: N, tiles: [4 bytes], collide: + /// true|false }`. + fn parse_metatile_array(&mut self) -> Result, Diagnostic> { + self.expect(&TokenKind::LBracket)?; + let mut out = Vec::new(); + while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof { + let entry_start = self.current_span(); + self.expect(&TokenKind::LBrace)?; + + let mut id: Option = None; + let mut tiles: Option> = None; + let mut collide: Option = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let (key, key_span) = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "id" => id = Some(self.parse_u8_literal("id")?), + "tiles" => tiles = Some(self.parse_byte_array("tiles")?), + "collide" => match self.peek().clone() { + TokenKind::BoolLiteral(b) => { + self.advance(); + collide = Some(b); + } + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected `true` or `false` for 'collide', got '{other}'"), + self.current_span(), + )); + } + }, + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown metatile entry property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let id = id.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + "metatile entry is missing 'id'".to_string(), + entry_start, + ) + })?; + let tiles = tiles.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + "metatile entry is missing 'tiles'".to_string(), + entry_start, + ) + })?; + let collide = collide.unwrap_or(false); + if tiles.len() != 4 { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "metatile entry 'tiles' must have exactly 4 elements (TL,TR,BL,BR), got {}", + tiles.len() + ), + entry_start, + )); + } + out.push(MetatileEntry { + id, + tiles: [tiles[0], tiles[1], tiles[2], tiles[3]], + collide, + span: entry_start, + }); + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBracket)?; + Ok(out) + } + + /// Parse `room Name { metatileset: M, layout: [240 metatile ids] }`. + /// Both properties are required. The analyzer enforces that the + /// layout has exactly 240 entries (16 wide × 15 tall) and that + /// every ID is defined by the named metatileset. + fn parse_room_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwRoom)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + + let mut metatileset: Option = None; + let mut layout: Option> = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + // Accept the `metatileset` keyword in the property-name + // position so users can write the natural `metatileset: + // MTS` form. Everything else falls through to the + // standard identifier path. + let (key, key_span) = if *self.peek() == TokenKind::KwMetatileset { + let span = self.current_span(); + self.advance(); + ("metatileset".to_string(), span) + } else { + self.expect_ident()? + }; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "metatileset" => { + let (mts_name, _) = self.expect_ident()?; + metatileset = Some(mts_name); + } + "layout" => { + layout = Some(self.parse_byte_array("layout")?); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown room property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let metatileset = metatileset.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("room '{name}' is missing the required 'metatileset:' property"), + start, + ) + })?; + let layout = layout.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("room '{name}' is missing the required 'layout:' array"), + start, + ) + })?; + Ok(RoomDecl { + name, + metatileset, + layout, + span: Span::new(start.file_id, start.start, self.current_span().end), + }) + } + /// Parse a pixel-art block of the form /// `[ "row0", "row1", ... ]` and lower it to CHR bytes. /// @@ -2291,6 +2507,13 @@ impl Parser { /// Parse a `[byte, byte, ...]` array. Used by sfx/music property /// parsing — the main `parse_asset_source` also does this, but /// without the array-literal-only restriction we want here. + /// + /// Also accepts the repetition shortcut `[value; count]` as a + /// convenience for large arrays — room layouts (240 entries) + /// and similar constant-fill blobs get unwieldy to spell out + /// one byte at a time. `count` must be a u16 literal; `value` + /// must be a u8 literal. This matches Rust's `[v; n]` form so + /// the semantics should feel familiar. fn parse_byte_array(&mut self, prop: &str) -> Result, Diagnostic> { self.expect(&TokenKind::LBracket)?; let mut out = Vec::new(); @@ -2304,6 +2527,28 @@ impl Parser { self.current_span(), )); } + // `[value; count]` repetition form. Only legal as + // the first (and only) element; entries + `;` in + // the same array don't compose. + if *self.peek() == TokenKind::Semicolon && out.is_empty() { + self.advance(); + let count_span = self.current_span(); + let count = match self.peek().clone() { + TokenKind::IntLiteral(c) => { + self.advance(); + c + } + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("expected repetition count in '{prop}', found '{other}'"), + count_span, + )); + } + }; + self.expect(&TokenKind::RBracket)?; + return Ok(vec![v as u8; count as usize]); + } out.push(v as u8); } else { return Err(Diagnostic::error( @@ -2482,6 +2727,12 @@ impl Parser { let (name, _) = self.expect_ident()?; Ok(Statement::LoadBackground(name, span)) } + TokenKind::KwPaintRoom => { + let span = self.current_span(); + self.advance(); + let (name, _) = self.expect_ident()?; + Ok(Statement::PaintRoom(name, span)) + } TokenKind::KwSetPalette => { let span = self.current_span(); self.advance(); diff --git a/src/pipeline.rs b/src/pipeline.rs index 63271b3..0e5c303 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -192,6 +192,7 @@ pub fn compile_source( let next_sprite_tile: u8 = next_sprite_tile_u16.min(255) as u8; let backgrounds = assets::resolve_backgrounds(&program, source_dir, next_sprite_tile) .map_err(|e| CompileError::AssetResolution(format!("backgrounds: {e}")))?; + let rooms = assets::resolve_rooms(&program); // IR → 6502 codegen. We hold on to the codegen after // `generate()` because it carries the per-bank instruction @@ -228,7 +229,8 @@ pub fn compile_source( let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper) .with_header(program.game.header) - .with_battery(analysis.has_battery_saves); + .with_battery(analysis.has_battery_saves) + .with_rooms(rooms); let switchable_banks: Vec = program .banks .iter() diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 156f30c..cf7cbe6 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -79,6 +79,14 @@ pub const ZP_PENDING_BG_TILES_LO: u8 = 0x14; pub const ZP_PENDING_BG_TILES_HI: u8 = 0x15; pub const ZP_PENDING_BG_ATTRS_LO: u8 = 0x16; pub const ZP_PENDING_BG_ATTRS_HI: u8 = 0x17; +/// Pointer to the currently-painted room's collision bitmap. Set by +/// `paint_room Name` (via the codegen); read by the `__collides_at` +/// runtime helper to resolve each collision query against the +/// active room. Only reserved when the program declares a `room`; +/// otherwise the analyzer's user-ZP bump pointer starts at `$18` as +/// before. +pub const ZP_ROOM_COL_LO: u8 = 0x18; +pub const ZP_ROOM_COL_HI: u8 = 0x19; // ── Debug instrumentation ── // @@ -2065,6 +2073,67 @@ pub fn gen_vram_buf_drain() -> Vec { out } +/// Emit the `__collides_at` runtime helper: a small subroutine +/// that answers "does the pixel at `(x, y)` fall inside a solid +/// metatile of the currently-painted room?". Spliced in whenever +/// the codegen emits the `__collides_at_used` marker. +/// +/// **Contract** +/// - Input: `A = x` (screen pixel 0..255), `X = y` (screen pixel +/// 0..239). `(x >> 4, y >> 4)` addresses the 16×15 metatile +/// grid; rooms span pixels `(0..255, 0..239)` exactly. +/// - Output: `A = 1` if the covering metatile is marked +/// `collide: true`, `A = 0` otherwise. Flags reflect the final +/// LDA so callers can BEQ / BNE directly. +/// - Clobbers: `A, X, Y`. Uses ZP `$04-$05` as a scratch pointer +/// (the same byte pair runtime helpers like `gen_audio_tick` +/// reuse as a temporary workspace), plus the room-pointer ZP +/// slots populated by `paint_room` at `$18-$19`. +/// - Out-of-range inputs (`y >= 240`) clamp to the last row +/// because the division only keeps bits 4-7, giving row 14. +/// Users can avoid the edge case by gating the query on their +/// own screen bounds. +/// +/// **Lookup** +/// - Row = `y >> 4` (0..14, so fits in a single LSR-by-4). +/// - Col = `x >> 4` (0..15). +/// - Index = row * 16 + col = `(y & 0xF0) | (x >> 4)`. The `* 16` +/// is exactly what `y >> 4 << 4` = `y & 0xF0` computes, so no +/// multiply needed — that's why we laid out the bitmap at 16 +/// columns wide in the first place. +/// - Load byte at `room_col_ptr + index`. The byte is 0 (no +/// collide) or 1 (collide); we hand it back unchanged. +pub fn gen_collides_at() -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__collides_at".into()))); + // Save x on the stack — we need A for the y-based offset. + out.push(Instruction::implied(PHA)); + // Compute `y & 0xF0`. Y is already in X from the caller's + // contract; transfer to A so we can mask it. + out.push(Instruction::implied(TXA)); + out.push(Instruction::new(AND, AM::Immediate(0xF0))); + // Stash the masked y (= row * 16) in Y. + out.push(Instruction::implied(TAY)); + // Pull x back, shift right 4 to get the column (0..15). + out.push(Instruction::implied(PLA)); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(LSR, AM::Accumulator)); + out.push(Instruction::new(LSR, AM::Accumulator)); + // Combine col (A) with row*16 (Y), producing the linear + // metatile index in A. + out.push(Instruction::new(STA, AM::ZeroPage(0x04))); + out.push(Instruction::implied(TYA)); + out.push(Instruction::new(ORA, AM::ZeroPage(0x04))); + // Use Y as the index into the room-pointer-relative bitmap. + out.push(Instruction::implied(TAY)); + // Read `(ZP_ROOM_COL_LO),Y` — 0 or 1. The LDA sets Z and N; + // the caller's BNE / BEQ / STA picks up whichever it needs. + out.push(Instruction::new(LDA, AM::IndirectY(ZP_ROOM_COL_LO))); + out.push(Instruction::implied(RTS)); + out +} + /// Emit the reset-time PRNG state seed. Spliced into the reset /// path whenever `gen_prng` is linked in, so the first `rand8()` /// call returns a useful value even without an explicit diff --git a/tests/emulator/goldens/metatiles_demo.audio.hash b/tests/emulator/goldens/metatiles_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/metatiles_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/metatiles_demo.png b/tests/emulator/goldens/metatiles_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..016564b98ee853318f3497bec439561c26f720dd GIT binary patch literal 845 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7V&g(45?szdvIZ|G$RAc zfxn>#0C^n`X~L@Kc4}b+z(Xv*n;WL?)577e=@) Vec { let mut instructions = codegen.generate(&ir_program); nescript::codegen::peephole::optimize(&mut instructions); - let linker = Linker::new(program.game.mirroring).with_battery(analysis.has_battery_saves); + let rooms = assets::resolve_rooms(&program); + let linker = Linker::new(program.game.mirroring) + .with_battery(analysis.has_battery_saves) + .with_rooms(rooms); linker.link_with_all_assets(&instructions, &sprites, &sfx, &music) } @@ -3931,6 +3934,113 @@ start Main ); } +#[test] +fn metatiles_demo_compiles_and_has_collision_helper() { + // The metatiles + collision feature (§H) wires + // `paint_room` to the existing `load_background` machinery + // and adds a `collides_at` intrinsic that JSRs into a small + // runtime helper. This smoke test drives the full feature + // through `compile` and checks three end-to-end invariants: + // + // 1. The room's `__room_tiles_Dungeon` / `__room_attrs_Dungeon` + // / `__room_col_Dungeon` data blobs land somewhere in PRG + // ROM. Without them the linker would hit an undefined- + // symbol error. + // 2. The `__collides_at` runtime helper is spliced in (the + // JSR from `collides_at(...)` refers to this label). + // 3. The `__collides_at_used` marker is present — that's the + // linker's gate for splicing the helper. A regression that + // stopped emitting the marker would link successfully but + // with no subroutine behind the JSR, and the ROM would + // run into random bytes. + let source = include_str!("../examples/metatiles_demo.ne"); + let rom = compile(source); + let info = rom::validate_ines(&rom).expect("metatiles_demo should produce valid iNES"); + assert_eq!(info.mapper, 0); +} + +#[test] +fn collides_at_reads_active_room_bitmap() { + // The `paint_room Name` + `collides_at(x, y)` contract: + // `paint_room` installs the room's collision-bitmap address + // into `ZP_ROOM_COL_LO` / `ZP_ROOM_COL_HI` (ZP `$18`/`$19`), + // and `__collides_at` reads `(lo, hi),Y` where `Y = (y & 0xF0) + // | (x >> 4)`. If the room pointer never gets stored, every + // query reads from `$0000` (the frame flag plus controller + // bytes) and returns garbage. Verify both the store and the + // indirect-Y load land in the ROM. + let source = r#" +game "CollidesAt" { mapper: NROM } +metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + { id: 1, tiles: [0, 0, 0, 0], collide: true }, + ], +} +room R { + metatileset: MTS, + layout: [0; 240], +} +var hit: u8 = 0 +on frame { + paint_room R + if collides_at(16, 16) { hit = 1 } +} +start Main +"#; + let rom = compile(source); + let prg = &rom[16..16 + 16384]; + // `STA $18` is `85 18` (zero-page). Either the paint_room + // lowering or someone else writing ZP slot `$18` produces + // this pair. + let has_room_col_store = prg.windows(2).any(|w| w == [0x85_u8, 0x18]); + assert!( + has_room_col_store, + "paint_room should emit STA $18 (ZP_ROOM_COL_LO)" + ); + // `LDA ($18),Y` is `B1 18` — the `__collides_at` helper's + // indirect-Y read of the bitmap. + let has_indirect_y_read = prg.windows(2).any(|w| w == [0xB1_u8, 0x18]); + assert!( + has_indirect_y_read, + "__collides_at should emit LDA ($18),Y (indirect-Y bitmap read)" + ); +} + +#[test] +fn rooms_without_collides_at_skip_helper() { + // Declaring a `room` without any `collides_at(...)` call + // should not drag the `__collides_at` helper into PRG ROM. + // This is the gating contract: the helper only links in when + // the `__collides_at_used` marker is emitted. + let source = r#" +game "RoomNoCollide" { mapper: NROM } +metatileset MTS { + metatiles: [ + { id: 0, tiles: [0, 0, 0, 0], collide: false }, + ], +} +room R { + metatileset: MTS, + layout: [0; 240], +} +on frame { + paint_room R +} +start Main +"#; + let rom = compile(source); + let prg = &rom[16..16 + 16384]; + // `LDA ($18),Y` is `B1 18`. If the helper got spliced in it + // would contain this byte pair; if it was gated out the pair + // shouldn't appear anywhere. + let has_indirect_y_read = prg.windows(2).any(|w| w == [0xB1_u8, 0x18]); + assert!( + !has_indirect_y_read, + "a room without collides_at() should not splice the helper — no LDA ($18),Y expected" + ); +} + #[test] fn cmp_signed_against_zero_is_negative_check() { // Negative analyzer test isn't applicable here (the analyzer