From 6b080316a4d8b7855a750e0a27c1e3401df19790 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 03:13:30 +0000 Subject: [PATCH] parser/lowering: declarative metasprites for multi-tile sprite groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tile sprites used to require one hand-written `draw` per tile, e.g. the four-call sequence in `examples/platformer.ne`'s `draw_player()`. The new `metasprite Name { ... }` declaration collects parallel `dx`/`dy`/`frame` arrays plus a reference to the underlying sprite, and `draw Name at: (x, y)` expands to one OAM slot per tile in the IR lowering — the codegen sees N regular DrawSprite ops, so the runtime OAM cursor allocator picks them up without any metasprite-specific awareness. The metasprite's `frame:` array is interpreted *relative to the underlying sprite's base tile*: index 0 means "the first tile this sprite owns", which is the natural reading for a 16×16 hero whose pixel art the asset resolver split into four consecutive tiles. The lowering walks `program.sprites` to compute base tile indices the same way `assets::resolve_sprites` would, then folds the base into each frame entry before storing the metasprite info. Sprites sourced from external `@chr(...)` / `@binary(...)` files whose bytes aren't available at parse time fall back to a one-tile assumption — those programs are rare and can declare metasprites against pixel-art sprites instead. The new `examples/metasprite_demo.ne` declares a 16×16 hero sprite and arranges its four tiles into a metasprite, then sweeps the hero across the screen so the harness captures it mid-motion. The new keyword is added to the lexer/token list, and the parser accepts `sprite:` (the otherwise-keyword) as a property name in metasprite bodies so the natural spelling parses. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB --- README.md | 1 + examples/README.md | 1 + examples/metasprite_demo.ne | 86 +++++++++++++ examples/metasprite_demo.nes | Bin 0 -> 24592 bytes src/analyzer/mod.rs | 62 +++++++++ src/analyzer/tests.rs | 121 ++++++++++++++++++ src/assets/audio.rs | 1 + src/assets/resolve.rs | 2 + src/ir/lowering.rs | 105 +++++++++++++++ src/ir/tests.rs | 63 +++++++++ src/lexer/mod.rs | 1 + src/lexer/token.rs | 2 + src/parser/ast.rs | 31 +++++ src/parser/mod.rs | 103 +++++++++++++++ src/parser/tests.rs | 39 ++++++ .../goldens/metasprite_demo.audio.hash | 1 + tests/emulator/goldens/metasprite_demo.png | Bin 0 -> 866 bytes 17 files changed, 619 insertions(+) create mode 100644 examples/metasprite_demo.ne create mode 100644 examples/metasprite_demo.nes create mode 100644 tests/emulator/goldens/metasprite_demo.audio.hash create mode 100644 tests/emulator/goldens/metasprite_demo.png diff --git a/README.md b/README.md index cf2772a..b51a968 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ start Main | [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` | | [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks | | [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone | +| [`metasprite_demo.ne`](examples/metasprite_demo.ne) | `metasprite Hero { sprite: ..., dx: [...], dy: [...], frame: [...] }` declarative multi-tile groups — `draw Hero at: (x, y)` expands to one OAM slot per tile so 16×16 sprites stop needing four hand-written `draw` statements | | [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds | ## Compiler Commands diff --git a/examples/README.md b/examples/README.md index e91ae79..cfa1c0c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,6 +32,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. | | `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. | | `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. | +| `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. | | `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). | | `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. | diff --git a/examples/metasprite_demo.ne b/examples/metasprite_demo.ne new file mode 100644 index 0000000..8e58228 --- /dev/null +++ b/examples/metasprite_demo.ne @@ -0,0 +1,86 @@ +// Metasprite demo — declarative multi-tile sprite groups. +// +// The `metasprite` keyword bundles a list of (dx, dy, frame) +// offsets into one named entity. `draw Hero at: (x, y)` then +// expands to one OAM slot per tile, so user code stops having +// to hand-write the four `draw Tileset at: (x+8, y+8) frame: +// TILE_PLAYER_BR` calls that platformer.ne uses today. Each +// tile inherits the OAM-cursor allocator from the runtime, so +// metasprites compose with regular `draw` statements without +// any extra wiring. +// +// Build: cargo run -- build examples/metasprite_demo.ne + +game "Metasprite Demo" { + mapper: NROM +} + +// 16×16 hero sprite drawn as ASCII pixel art. The parser splits +// it into four 8×8 tiles in row-major order: tile 0 = top-left, +// tile 1 = top-right, tile 2 = bottom-left, tile 3 = bottom-right. +// We refer to those four tiles by base offset below. +sprite Hero16 { + pixels: [ + "..####....####..", + ".######..######.", + "###@##....##@###", + "###@##....##@###", + "################", + "################", + ".##############.", + "..############..", + "...##########...", + "....########....", + "....##....##....", + "....##....##....", + "....##....##....", + "....##....##....", + "...####..####...", + "..######..######" + ] +} + +// Arrange Hero16's four tiles into a 16×16 metasprite. Each +// row in the metasprite block is logically one OAM slot; the +// `dx` / `dy` arrays carry the per-tile offset from the +// metasprite's anchor point and `frame` carries the tile index. +// +// `sprite: Hero16` resolves to the base tile index reserved by +// the asset resolver; the four entries below pick consecutive +// tiles starting at that base — i.e. the four 8×8 tiles the +// pixel-art block was sliced into. +metasprite Hero { + sprite: Hero16 + dx: [0, 8, 0, 8] + dy: [0, 0, 8, 8] + frame: [0, 1, 2, 3] +} + +var px: u8 = 120 +var py: u8 = 96 +var dir: u8 = 0 // 0 = right, 1 = left + +on frame { + // Bounce horizontally between two rails so the harness + // captures movement at frame 180 regardless of which + // direction we started in. + if dir == 0 { + px += 1 + if px == 200 { + dir = 1 + } + } else { + px -= 1 + if px == 32 { + dir = 0 + } + } + + // One `draw Hero` expands to four `draw Hero16` calls under + // the hood. Compare with the four-line sequence in + // platformer.ne's `draw_player()` — same OAM layout, much + // less typing. + draw Hero at: (px, py) +} + +start Main diff --git a/examples/metasprite_demo.nes b/examples/metasprite_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..c445d4d1e3f2ce2db0d9a5b6a656c8cd4d4c2246 GIT binary patch literal 24592 zcmeI)F^dyH6bJA(Gn?E7gxiGkE?#l2A_2wX5DSq&mIEb7w6T|Kgjm^03W<8jHp0r4 zM%X4e;0VF5V6G7Fs+6`C9#=`M6bu*NOUxpot%dw2;mzAOFAwI&*12`<_PSD(t=5Nr z^lUBaPhU7nC`?qCoQ#~2u$fSlc#-oH6(zaI(M25w-yQWSCH^XRoRW5&nUuVgaw+9g zS~?niocBHEaRVK?T9OZWHizY8Niq(Bgiyk)mX~Z*LM5veBs(SHd{$GE6(yX>YJZxy zBB$phT+aH|CA%QuYE~;rc2>eA2?5{Uwc@~S$3;hRDK?hI6VAw*eHxp_#;{MbTOaWn zedUvaE%8atQRC*&sd4!*u)+TOS>cWpJg4GPpJza zV7omgRXxS7J*{o0XY^rV{*i7!?#{bAPIGrhx^>pw{jK}wZ2plmi`gdY{>yAX-f;82%JH#Ge-t_(JdUvU8&0Q}xdGBB}wq|>Al=mvenk%35E*~E9;b-e@*Pqs( z*R8HwQ|C9qgia?bkyFVkzg`^r{$Mq_QBi|6j{d>u3-eJkHqBT!Uo~GhqiBi+0SG_< z0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*y_009U<;J*{NzejK1>{Yky6WiOWR@-vHHt&ewLw0SG_<0uX=z1Rwwb2tWV= f5P$##AOHafKmY;|fB*y_009U<00I#BcLF~ErotBH literal 0 HcmV?d00001 diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 7e150b0..eae8acf 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -396,6 +396,68 @@ impl Analyzer { } } + // Validate metasprite declarations: the parallel offset + // arrays must all be the same length, the named sprite + // must exist, and the metasprite name must be unique + // (against other metasprites and against sprites — both + // share the same `draw` lookup namespace). + let mut seen_metasprites = HashSet::new(); + let sprite_names: HashSet = + program.sprites.iter().map(|s| s.name.clone()).collect(); + for ms in &program.metasprites { + if !seen_metasprites.insert(ms.name.clone()) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate metasprite '{}'", ms.name), + ms.span, + )); + } + if sprite_names.contains(&ms.name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!( + "metasprite '{}' shadows a sprite with the same name; pick a unique identifier", + ms.name + ), + ms.span, + )); + } + if !sprite_names.contains(&ms.sprite_name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metasprite '{}' references unknown sprite '{}'", + ms.name, ms.sprite_name + ), + ms.span, + )); + } + if ms.dx.len() != ms.dy.len() || ms.dx.len() != ms.frame.len() { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metasprite '{}' has mismatched array lengths: \ + dx={}, dy={}, frame={} — all three must be equal", + ms.name, + ms.dx.len(), + ms.dy.len(), + ms.frame.len() + ), + ms.span, + )); + } + if ms.dx.is_empty() { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "metasprite '{}' is empty — declare at least one tile", + ms.name + ), + ms.span, + )); + } + } + // Register functions as symbols for fun in &program.functions { self.register_fun(fun); diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 0cd09fd..44e8d03 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -489,6 +489,127 @@ fn analyze_struct_with_unknown_inner_struct_errors() { ); } +#[test] +fn analyze_metasprite_ok() { + let result = analyze_ok( + r#" + game "T" { mapper: NROM } + sprite Tile { + pixels: [ + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@" + ] + } + metasprite Hero { + sprite: Tile + dx: [0, 8] + dy: [0, 0] + frame: [0, 0] + } + on frame { draw Hero at: (10, 10) wait_frame } + start Main + "#, + ); + // Sanity: the metasprite was kept around in the program. + // (The analyzer doesn't move declarations into AnalysisResult, + // so we only check that no errors were emitted; the + // lowering test below validates the expansion path.) + assert!(result.diagnostics.iter().all(|d| !d.is_error())); +} + +#[test] +fn analyze_metasprite_unknown_sprite_errors() { + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + metasprite Hero { + sprite: NotASprite + dx: [0] + dy: [0] + frame: [0] + } + on frame { wait_frame } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "metasprite referencing an unknown sprite should emit E0201, got: {errors:?}" + ); +} + +#[test] +fn analyze_metasprite_mismatched_array_lengths_errors() { + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + sprite Tile { + pixels: [ + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@" + ] + } + metasprite Hero { + sprite: Tile + dx: [0, 8, 0] + dy: [0, 0] + frame: [0, 1, 2] + } + on frame { wait_frame } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "mismatched dx/dy/frame lengths should emit E0201, got: {errors:?}" + ); +} + +#[test] +fn analyze_metasprite_empty_errors() { + let errors = analyze_errors( + r#" + game "T" { mapper: NROM } + sprite Tile { + pixels: [ + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@" + ] + } + metasprite Hero { + sprite: Tile + dx: [] + dy: [] + frame: [] + } + on frame { wait_frame } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "empty metasprite should emit E0201, got: {errors:?}" + ); +} + #[test] fn analyze_struct_with_array_of_structs_is_rejected() { // Arrays of structs aren't supported yet — the synthetic- diff --git a/src/assets/audio.rs b/src/assets/audio.rs index cbffefe..e4f6824 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -770,6 +770,7 @@ mod tests { sprites: Vec::new(), palettes: Vec::new(), backgrounds: Vec::new(), + metasprites: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index b2d2374..138d3a5 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -215,6 +215,7 @@ mod tests { sprites: vec![sprite], palettes: Vec::new(), backgrounds: Vec::new(), + metasprites: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), @@ -293,6 +294,7 @@ mod tests { sprites: Vec::new(), palettes: Vec::new(), backgrounds: Vec::new(), + metasprites: Vec::new(), sfx: Vec::new(), music: Vec::new(), banks: Vec::new(), diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 2373c76..41d7cfc 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -46,6 +46,23 @@ struct LoweringContext { /// by binary-op, compare, and assignment lowering when they /// need to decide between `Add`/`Add16`, etc. wide_hi: HashMap, + /// Captured metasprite declarations keyed by name. When a + /// `Statement::Draw` names a metasprite (rather than a flat + /// sprite), the lowering expands it inline into one + /// [`IrOp::DrawSprite`] per tile, with x/y offsets folded into + /// the per-tile coordinates and the metasprite's `frame:` + /// entry used as the literal frame index. Storing the lookup + /// here keeps the per-statement lowering simple and avoids + /// having to thread the program through every helper. + metasprites: HashMap, +} + +#[derive(Debug, Clone)] +struct MetaspriteInfo { + sprite_name: String, + dx: Vec, + dy: Vec, + frame: Vec, } struct LoopContext { @@ -92,6 +109,7 @@ impl LoweringContext { state_names: Vec::new(), start_state: String::new(), wide_hi: HashMap::new(), + metasprites: HashMap::new(), } } @@ -269,6 +287,52 @@ impl LoweringContext { self.state_names = program.states.iter().map(|s| s.name.clone()).collect(); program.start_state.clone_into(&mut self.start_state); + // Capture metasprite declarations so the per-statement + // Draw lowering can expand `draw Hero` into one + // DrawSprite op per tile. The `frame:` array in a + // metasprite is interpreted *relative to the underlying + // sprite's base tile* — i.e. `frame: [0, 1, 2, 3]` on a + // 16×16 sprite means "the four tiles this sprite owns". + // Since the IR codegen's DrawSprite op takes an *absolute* + // tile index whenever `frame` is set, we need to resolve + // the per-sprite base tile here and rewrite the array + // before storing it. + // + // Tile assignment mirrors `assets::resolve_sprites`: tile + // index 0 is reserved for the runtime's default smiley, + // user sprites start at 1, and each sprite consumes + // `chr_bytes.len() / 16` tiles (rounded up). Sprites with + // an external `@chr(...)` / `@binary(...)` source whose + // bytes aren't available at parse time fall back to a + // single-tile assumption — that's a regression for those + // exotic sources but keeps the in-tree examples working. + let mut sprite_base: HashMap = HashMap::new(); + let mut next_tile: u8 = 1; + for sprite in &program.sprites { + sprite_base.insert(sprite.name.clone(), next_tile); + let tile_count = match &sprite.chr_source { + crate::parser::ast::AssetSource::Inline(bytes) => { + (bytes.len().div_ceil(16)).max(1) as u8 + } + _ => 1, + }; + next_tile = next_tile.saturating_add(tile_count); + } + for ms in &program.metasprites { + let base = sprite_base.get(&ms.sprite_name).copied().unwrap_or(0); + let resolved_frames: Vec = + ms.frame.iter().map(|&f| base.saturating_add(f)).collect(); + self.metasprites.insert( + ms.name.clone(), + MetaspriteInfo { + sprite_name: ms.sprite_name.clone(), + dx: ms.dx.clone(), + dy: ms.dy.clone(), + frame: resolved_frames, + }, + ); + } + // Register enum variants first so constants that reference // them (e.g. `const FIRST: u8 = VariantA`) can resolve. for e in &program.enums { @@ -560,6 +624,47 @@ impl LoweringContext { self.start_block(&cont); } Statement::Draw(draw) => { + if let Some(meta) = self.metasprites.get(&draw.sprite_name).cloned() { + // Metasprite expansion: for each tile in the + // declaration, emit one DrawSprite with x/y + // offset by (dx[i], dy[i]) and frame = frame[i]. + // The IR codegen sees N independent draws so + // the runtime OAM-cursor path picks them up + // exactly like a hand-written sequence of + // `draw` statements. + // + // The user's `frame:` argument is ignored when + // drawing a metasprite — the per-tile frame + // index comes from the declaration. The + // analyzer doesn't currently flag this; future + // work could warn on it. + let base_x = self.lower_expr(&draw.x); + let base_y = self.lower_expr(&draw.y); + for ((dx_off, dy_off), tile) in + meta.dx.iter().zip(&meta.dy).zip(&meta.frame) + { + let off_x = self.fresh_temp(); + self.emit(IrOp::LoadImm(off_x, *dx_off)); + let x_sum = self.fresh_temp(); + self.emit(IrOp::Add(x_sum, base_x, off_x)); + + let off_y = self.fresh_temp(); + self.emit(IrOp::LoadImm(off_y, *dy_off)); + let y_sum = self.fresh_temp(); + self.emit(IrOp::Add(y_sum, base_y, off_y)); + + let tile_imm = self.fresh_temp(); + self.emit(IrOp::LoadImm(tile_imm, *tile)); + + self.emit(IrOp::DrawSprite { + sprite_name: meta.sprite_name.clone(), + x: x_sum, + y: y_sum, + frame: Some(tile_imm), + }); + } + return; + } let x = self.lower_expr(&draw.x); let y = self.lower_expr(&draw.y); let frame = draw.frame.as_ref().map(|e| self.lower_expr(e)); diff --git a/src/ir/tests.rs b/src/ir/tests.rs index 83706c5..311c9c9 100644 --- a/src/ir/tests.rs +++ b/src/ir/tests.rs @@ -350,6 +350,69 @@ fn lower_debug_frame_overrun_count_emits_peek() { ); } +#[test] +fn lower_metasprite_draw_expands_to_one_op_per_tile() { + // `draw Hero at: (10, 20)` where Hero is a 4-tile metasprite + // should lower to four `DrawSprite` ops, each with the + // metasprite's underlying sprite name and one tile from the + // declaration's `frame:` array (offset by the sprite's base + // tile index — the runtime smiley occupies tile 0, so a + // single-sprite program starts user tiles at 1). + let ir = lower_ok( + r#" + game "T" { mapper: NROM } + sprite Tile { + pixels: [ + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@" + ] + } + metasprite Hero { + sprite: Tile + dx: [0, 8, 0, 8] + dy: [0, 0, 8, 8] + frame: [0, 0, 0, 0] + } + on frame { + draw Hero at: (10, 20) + wait_frame + } + start Main + "#, + ); + let frame_fn = ir + .functions + .iter() + .find(|f| f.name.contains("frame")) + .unwrap(); + let draws: Vec<_> = frame_fn + .blocks + .iter() + .flat_map(|b| &b.ops) + .filter_map(|op| match op { + IrOp::DrawSprite { sprite_name, .. } => Some(sprite_name.clone()), + _ => None, + }) + .collect(); + assert_eq!( + draws.len(), + 4, + "metasprite with 4 tiles should expand to 4 DrawSprite ops" + ); + for name in &draws { + assert_eq!( + name, "Tile", + "expanded ops should target the underlying sprite" + ); + } +} + #[test] fn lower_nested_struct_literal_init_expands_to_leaves() { // A `Hero { pos: Vec2 { x: 1, y: 2 }, hp: 100, inv: [3,4,5,6] }` diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 1955db1..b9c8af7 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -462,6 +462,7 @@ impl<'a> Lexer<'a> { "start" => TokenKind::KwStart, "transition" => TokenKind::KwTransition, "sprite" => TokenKind::KwSprite, + "metasprite" => TokenKind::KwMetasprite, "background" => TokenKind::KwBackground, "palette" => TokenKind::KwPalette, "sfx" => TokenKind::KwSfx, diff --git a/src/lexer/token.rs b/src/lexer/token.rs index d76e82c..0e59d14 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -66,6 +66,7 @@ pub enum TokenKind { KwStart, KwTransition, KwSprite, + KwMetasprite, KwBackground, KwPalette, KwSfx, @@ -175,6 +176,7 @@ impl std::fmt::Display for TokenKind { Self::KwStart => write!(f, "start"), Self::KwTransition => write!(f, "transition"), Self::KwSprite => write!(f, "sprite"), + Self::KwMetasprite => write!(f, "metasprite"), Self::KwBackground => write!(f, "background"), Self::KwPalette => write!(f, "palette"), Self::KwSfx => write!(f, "sfx"), diff --git a/src/parser/ast.rs b/src/parser/ast.rs index df14876..77fc28a 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -12,6 +12,7 @@ pub struct Program { pub sprites: Vec, pub palettes: Vec, pub backgrounds: Vec, + pub metasprites: Vec, pub sfx: Vec, pub music: Vec, pub banks: Vec, @@ -19,6 +20,36 @@ pub struct Program { pub span: Span, } +/// `metasprite Name { sprite: Tileset, dx: [...], dy: [...], frame: [...] }` +/// — a multi-tile sprite group authored as parallel offset arrays. +/// `draw Name at: (x, y)` lowers to one `DrawSprite` per tile, with +/// each tile's screen position computed as `(x + dx[i], y + dy[i])` +/// and its tile index taken from `frame[i]`. The underlying +/// `sprite:` field names a previously-declared sprite/tileset that +/// owns the actual CHR data — metasprites only describe layout. +/// +/// All three offset/frame arrays must have the same length, which +/// becomes the metasprite's tile count. The lowering does the +/// per-tile cursor bump through the existing OAM cursor path so a +/// metasprite that draws four tiles consumes four OAM slots in the +/// same order the user wrote them. +/// +/// Today only u8 (unsigned) offsets are supported. Negative +/// offsets aren't representable in the current `NesType::U8` array +/// literals — see `docs/future-work.md`. +#[derive(Debug, Clone)] +pub struct MetaspriteDecl { + pub name: String, + /// Underlying CHR-bearing sprite/tileset whose tiles are + /// indexed by this metasprite's `frame:` entries. Looked up + /// in [`Program::sprites`] at analysis time. + pub sprite_name: String, + pub dx: Vec, + pub dy: Vec, + pub frame: 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 diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 1816a9d..5047177 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -129,6 +129,7 @@ impl Parser { let mut sprites = Vec::new(); let mut palettes = Vec::new(); let mut backgrounds = Vec::new(); + let mut metasprites = Vec::new(); let mut sfx = Vec::new(); let mut music = Vec::new(); let mut banks = Vec::new(); @@ -165,6 +166,9 @@ impl Parser { TokenKind::KwSprite => { sprites.push(self.parse_sprite_decl()?); } + TokenKind::KwMetasprite => { + metasprites.push(self.parse_metasprite_decl()?); + } TokenKind::KwPalette => { palettes.push(self.parse_palette_decl()?); } @@ -253,6 +257,7 @@ impl Parser { sprites, palettes, backgrounds, + metasprites, sfx, music, banks, @@ -852,6 +857,104 @@ impl Parser { }) } + /// Parse a `metasprite Name { sprite: ..., dx: [...], dy: [...], + /// frame: [...] }` block. The body uses parallel byte arrays so + /// the parser can reuse the existing [`Self::parse_byte_array`] + /// helper for each one — the analyzer is responsible for + /// asserting they're all the same length and that the named + /// sprite exists. + fn parse_metasprite_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwMetasprite)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + + let mut sprite_name: Option = None; + let mut dx: Option> = None; + let mut dy: Option> = None; + let mut frame: Option> = None; + + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + // Accept either a regular identifier or the `sprite` + // keyword in the property-name position. We fall + // through to the standard ident path for everything + // else so misspellings still produce the usual + // E0201 diagnostic. + let (key, key_span) = if *self.peek() == TokenKind::KwSprite { + let span = self.current_span(); + self.advance(); + ("sprite".to_string(), span) + } else { + self.expect_ident()? + }; + self.expect(&TokenKind::Colon)?; + match key.as_str() { + "sprite" => { + let (sname, _) = self.expect_ident()?; + sprite_name = Some(sname); + } + "dx" => { + dx = Some(self.parse_byte_array("dx")?); + } + "dy" => { + dy = Some(self.parse_byte_array("dy")?); + } + "frame" => { + frame = Some(self.parse_byte_array("frame")?); + } + _ => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!("unknown metasprite property '{key}'"), + key_span, + )); + } + } + if *self.peek() == TokenKind::Comma { + self.advance(); + } + } + self.expect(&TokenKind::RBrace)?; + + let sprite_name = sprite_name.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("metasprite '{name}' is missing the required 'sprite:' property"), + start, + ) + })?; + let dx = dx.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("metasprite '{name}' is missing the required 'dx:' array"), + start, + ) + })?; + let dy = dy.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("metasprite '{name}' is missing the required 'dy:' array"), + start, + ) + })?; + let frame = frame.ok_or_else(|| { + Diagnostic::error( + ErrorCode::E0201, + format!("metasprite '{name}' is missing the required 'frame:' array"), + start, + ) + })?; + + Ok(MetaspriteDecl { + name, + sprite_name, + dx, + dy, + frame, + 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. /// diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 43e5de1..7d8e4f8 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -254,6 +254,45 @@ fn parse_draw_with_frame() { } } +#[test] +fn parse_metasprite_decl() { + // A metasprite collects parallel `dx` / `dy` / `frame` + // arrays plus a reference to the underlying sprite. The + // parser preserves them verbatim — array length validation + // happens later in the analyzer. + let src = r#" + game "T" { mapper: NROM } + sprite Tile { + pixels: [ + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@", + "@@@@@@@@" + ] + } + metasprite Hero { + sprite: Tile + dx: [0, 8, 0, 8] + dy: [0, 0, 8, 8] + frame: [0, 1, 2, 3] + } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.metasprites.len(), 1); + let ms = &prog.metasprites[0]; + assert_eq!(ms.name, "Hero"); + assert_eq!(ms.sprite_name, "Tile"); + assert_eq!(ms.dx, vec![0, 8, 0, 8]); + assert_eq!(ms.dy, vec![0, 0, 8, 8]); + assert_eq!(ms.frame, vec![0, 1, 2, 3]); +} + // ── Expressions ── #[test] diff --git a/tests/emulator/goldens/metasprite_demo.audio.hash b/tests/emulator/goldens/metasprite_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/metasprite_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/metasprite_demo.png b/tests/emulator/goldens/metasprite_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e091f69f5f6b39d9c1953edb19e17af0e77d99 GIT binary patch literal 866 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ*7S6745?szd(bgU#*KmH zK-gpPbIRW>;zLd(I!fH0H2HMbx>eg%-ZQG19$3RR<0`{use~}*Gg%FhyauZo(?*Ek zUyl2SEY;=jR)4+-rkLkvR#ZNp^XJW+$;*rV>dMY>)+~FtbZ+sdFBLYQO5UHnyw}{n z|G2XM^En6Rn;cjJ48Mn|K-u~`)y(@Z1A{Q`)?V}fSNEEKQ0LWue{c5w()YYT1EP0) zf42JF-p2JAKg{0k{doo`A8LPSJJ39Lph5THVO}Hse2xv!xRP@xFI%dA2+jArclfmR z^C#gzP1h~e_ZPkA{c|pQ(szc*BQdUMlqBmJ`h+bHPHE%_24x{nS3j3^P6