1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00

metatiles + collision: metatileset, room, paint_room, collides_at

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_<name>`)
- a 64-byte attribute table (`__room_attrs_<name>`)
- a 240-byte collision bitmap (`__room_col_<name>`)

`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.
This commit is contained in:
Claude 2026-04-19 01:28:17 +00:00
parent 9719dc4111
commit 82b3d0d20a
No known key found for this signature in database
23 changed files with 1344 additions and 60 deletions

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

View file

@ -43,7 +43,10 @@ fn compile(source: &str) -> Vec<u8> {
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