From 7294ae3efae7bf1a878581862c5baad9d86c43e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 02:19:49 +0000 Subject: [PATCH] analyzer/lowering: support nested struct fields and array struct fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Struct field types beyond the v1 scalar set (`u8`, `i8`, `u16`, `bool`) used to error out with `E0201: struct fields must be u8/i8/u16/bool`. The size accumulator already handled them correctly — what was missing was: (1) the analyzer side that synthesizes per-leaf symbols and allocations for nested structs plus a single array-typed symbol for array fields, (2) the parser's chained-field-access path, and (3) the IR-lowering recursion through nested struct literal initializers and array literal field values. The synthetic-variable model carries through unchanged: a `var p: Player` where `Player { pos: Vec2, hp: u8, inv: u8[4] }` and `Vec2 { x: u8, y: u8 }` produces flat allocations for `p.pos.x`, `p.pos.y`, `p.hp`, and `p.inv`, plus an intermediate `p.pos` Struct symbol so dotted-name lookups still resolve. Array fields get a single allocation with the array type so the existing `Expr::ArrayIndex` lowering path handles `p.inv[i]` without changes. Array-of-structs is still rejected with E0201 because the synthetic model can't index per-element layouts without further codegen work. The parser change is the only structural move: `parse_primary` and `parse_assign_or_call` now loop the dot chain into a single joined identifier so `p.pos.x` becomes `FieldAccess("p.pos", "x")` and `p.inv[0]` becomes `ArrayIndex("p.inv", 0)`. The downstream analyzer and IR lowering use the same `format!("{name}.{field}")` join they already used for one-level access — no plumbing changes required. Includes a new `examples/nested_structs.ne` that exercises both features end-to-end with two `Hero` instances carrying nested positions and inventory arrays. The reproducibility tripwire ROM is committed alongside it and the emulator harness has a matching pair of golden files. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB --- README.md | 1 + examples/README.md | 1 + examples/nested_structs.ne | 54 ++++++ examples/nested_structs.nes | Bin 0 -> 24592 bytes examples/structs_enums_for.ne | 5 +- src/analyzer/mod.rs | 178 +++++++++++++----- src/analyzer/tests.rs | 131 +++++++++++-- src/ir/lowering.rs | 92 ++++++--- src/ir/tests.rs | 39 ++++ src/parser/mod.rs | 66 ++++++- .../goldens/nested_structs.audio.hash | 1 + tests/emulator/goldens/nested_structs.png | Bin 0 -> 951 bytes 12 files changed, 478 insertions(+), 90 deletions(-) create mode 100644 examples/nested_structs.ne create mode 100644 examples/nested_structs.nes create mode 100644 tests/emulator/goldens/nested_structs.audio.hash create mode 100644 tests/emulator/goldens/nested_structs.png diff --git a/README.md b/README.md index 12d7f14..af8b6c6 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ start Main | [`palette_and_background.ne`](examples/palette_and_background.ne) | Palette and background declarations, reset-time load, vblank-safe `set_palette` / `load_background` swaps | | [`friendly_assets.ne`](examples/friendly_assets.ne) | **Pleasant asset syntax** — named NES colours, grouped `bg0..sp3` palettes with `universal:`, ASCII pixel-art sprites, `legend { } + map:` tilemaps, `palette_map:` attribute grids, scalar sfx `pitch:`, note-name music with `tempo:` | | [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals | +| [`nested_structs.ne`](examples/nested_structs.ne) | Nested-struct fields (`hero.pos.x`) and array struct fields (`hero.inv[0]`) with chained literal initializers | | [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` | | [`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 | diff --git a/examples/README.md b/examples/README.md index b54c938..53a0ea3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -30,6 +30,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps | | `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. | +| `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`. | ## Emulator Controls diff --git a/examples/nested_structs.ne b/examples/nested_structs.ne new file mode 100644 index 0000000..dc27b6b --- /dev/null +++ b/examples/nested_structs.ne @@ -0,0 +1,54 @@ +// Demonstrates nested struct fields and array fields inside structs. +// +// Two `Hero` instances each carry a `Vec2` position (a nested struct) +// and a small inventory `u8[4]` (an array field). Both heroes scoot +// across the screen in opposite directions, and their inventory bytes +// are advanced once per frame so the analyzer's flat-allocation model +// is exercised end-to-end. +// +// Compile with the default IR codegen: +// nescript build examples/nested_structs.ne + +game "NestedStructs" { mapper: NROM } + +// Inner structs must be declared before any struct that nests them — +// the analyzer doesn't topologically sort declarations. +struct Vec2 { + x: u8, + y: u8, +} + +// `pos` is a nested-struct field; `inv` is an array field. Both are +// fully addressable as `hero.pos.x` / `hero.inv[i]`. Layout in RAM +// is contiguous: pos.x, pos.y, hp, inv[0..3] — total 7 bytes per +// instance. +struct Hero { + pos: Vec2, + hp: u8, + inv: u8[4], +} + +// Hero positions are initialized inline so the on-frame handler +// only has to step them. Inventory bytes are a separate `var` +// because struct literals don't accept array fields yet. +var hero1: Hero = Hero { pos: Vec2 { x: 32, y: 96 }, hp: 100, inv: [1, 2, 3, 4] } +var hero2: Hero = Hero { pos: Vec2 { x: 200, y: 128 }, hp: 100, inv: [10, 20, 30, 40] } + +on frame { + // Walk both heroes — hero1 moves right, hero2 moves left, both + // wrap around at the screen edge so the demo runs forever. + hero1.pos.x += 1 + hero2.pos.x -= 1 + + // Cycle the inventory bytes so the synthetic-array allocation + // path takes a real read/write pair every frame. + hero1.inv[0] += 1 + hero2.inv[0] += 1 + + draw Smiley at: (hero1.pos.x, hero1.pos.y) + draw Smiley at: (hero2.pos.x, hero2.pos.y) + + wait_frame +} + +start Main diff --git a/examples/nested_structs.nes b/examples/nested_structs.nes new file mode 100644 index 0000000000000000000000000000000000000000..df2924493d535dcab69b4be9f2b2503a52984cbe GIT binary patch literal 24592 zcmeI)F^dyH6bJA(^ER7JRE!dH=M}Pw94ZPT79s(c10{&q*jc4eh=sk?ao(|wFmk04 zwpko-4)!AmdX-)wDQzu0t~z36p?JQR;}%7~fbgG$e;#ihlgTfsbNA-G1!JgQPd=5| z^SP|tdl|@4>P+fRWPvpE%|$VdxBp2hMUZjyYHtv6wVCL@ws=-ZeR_xI{-< z9NMnUq3aHDIOPs=IOmRVpqxETf5ZOwkA{0!-K6jT4c}HzSwoy+?LH<9}Nf31}_HQ4m=O!d*oDLx|tC5 z0KT~>d;kCd literal 0 HcmV?d00001 diff --git a/examples/structs_enums_for.ne b/examples/structs_enums_for.ne index 0001e74..8862163 100644 --- a/examples/structs_enums_for.ne +++ b/examples/structs_enums_for.ne @@ -16,8 +16,9 @@ enum Direction { Up, Down, Left, Right } enum AnimFrame { Idle, Run1, Run2 } -// A struct bundles related state into a single variable. Only u8 / -// i8 / bool fields are allowed in v0.1 — no nesting, arrays, or u16. +// A struct bundles related state into a single variable. +// See examples/nested_structs.ne for nested-struct and array-field +// fields; this example sticks to flat scalar fields for simplicity. struct Player { x: u8, y: u8, diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 4ec4961..7e150b0 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -841,9 +841,10 @@ impl Analyzer { /// Register a struct declaration. Computes each field's byte /// offset from the base address (fields are laid out contiguously /// in declaration order with no padding), and records the total - /// size. Fields may be u8, i8, bool, or u16. Nested structs and - /// array fields are still rejected — the IR-lowering path doesn't - /// model them yet. + /// size. Field types may be `u8`, `i8`, `bool`, `u16`, an array + /// of any of those, or another previously-declared struct. + /// Nested struct fields require the inner struct to have been + /// declared earlier in the program (we don't topologically sort). fn register_struct(&mut self, s: &StructDecl) { if self.struct_layouts.contains_key(&s.name) { self.diagnostics.push(Diagnostic::error( @@ -853,25 +854,60 @@ impl Analyzer { )); return; } + // Snapshot the existing per-struct sizes so the size + // helper can resolve nested struct field sizes without + // borrowing `self` mutably. + let struct_sizes: HashMap = self + .struct_layouts + .iter() + .map(|(n, l)| (n.clone(), l.size)) + .collect(); let mut fields = Vec::new(); let mut offset: u16 = 0; for field in &s.fields { - // Reject non-primitive field types for now. u16 is - // allowed and takes two bytes; arrays and nested structs - // still error out with a clearer message than before. + // Compute the size for this field. Primitives are 1 or + // 2 bytes; arrays multiply element size by length; + // nested structs look up the previously-registered + // size. A nested struct that hasn't been declared yet + // is an error — the user must put inner structs + // before the outer ones. let size = match &field.field_type { NesType::U8 | NesType::I8 | NesType::Bool => 1, NesType::U16 => 2, - NesType::Array(_, _) | NesType::Struct(_) => { - self.diagnostics.push(Diagnostic::error( - ErrorCode::E0201, - format!( - "struct field '{}' has unsupported type '{}' (struct fields must be u8, i8, u16, or bool)", - field.name, field.field_type - ), - field.span, - )); - continue; + NesType::Array(elem, count) => { + // Reject arrays of structs for now — the + // synthetic-variable model used by the + // analyzer flattens scalars into one symbol + // per leaf, but an array-of-structs would + // need either per-element flattening or a + // proper indexed-struct codegen path. + if let NesType::Struct(_) = elem.as_ref() { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "struct field '{}' is an array of structs, which is not yet supported", + field.name + ), + field.span, + )); + continue; + } + let elem_size = type_size_with(elem, &struct_sizes); + elem_size * *count + } + NesType::Struct(sname) => { + let Some(inner) = struct_sizes.get(sname).copied() else { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "struct '{}' field '{}' references unknown struct type '{sname}'; declare '{sname}' before '{}'", + s.name, field.name, s.name + ), + field.span, + )); + continue; + }; + inner } }; fields.push((field.name.clone(), field.field_type.clone(), offset)); @@ -919,6 +955,76 @@ impl Analyzer { } } + /// Recursively walk a struct layout and synthesize one symbol + + /// allocation per leaf field, plus a Struct-typed symbol for + /// each nested-struct intermediate so dotted-name lookups for + /// `outer.inner` (without the trailing leaf) still resolve. + /// + /// For example, given `var p: Player` where `Player { pos: + /// Point, hp: u8, inv: u8[4] }` and `Point { x: u8, y: u8 }`, + /// this produces: + /// + /// - `p.pos` — Symbol(Struct("Point")) + /// - `p.pos.x` — Symbol(U8) + allocation + /// - `p.pos.y` — Symbol(U8) + allocation + /// - `p.hp` — Symbol(U8) + allocation + /// - `p.inv` — Symbol(Array(U8, 4)) + allocation + fn flatten_struct_fields( + &mut self, + base_name: &str, + base_addr: u16, + layout: &StructLayout, + var_span: Span, + ) { + for (field_name, field_type, offset) in &layout.fields { + let full_name = format!("{base_name}.{field_name}"); + let field_addr = base_addr + offset; + match field_type { + NesType::Struct(sname) => { + // Register the intermediate as a Struct + // symbol so a `name.field` walk finds it + // even when only the leaves carry storage. + self.symbols.insert( + full_name.clone(), + Symbol { + name: full_name.clone(), + sym_type: field_type.clone(), + is_const: false, + span: var_span, + }, + ); + let nested = self.struct_layouts[sname].clone(); + self.flatten_struct_fields(&full_name, field_addr, &nested, var_span); + } + _ => { + // u8 / i8 / u16 / bool / array — leaf field. + // The leaf's allocation size mirrors the + // top-level rule used by `register_var`. + self.symbols.insert( + full_name.clone(), + Symbol { + name: full_name.clone(), + sym_type: field_type.clone(), + is_const: false, + span: var_span, + }, + ); + let struct_sizes: HashMap = self + .struct_layouts + .iter() + .map(|(n, l)| (n.clone(), l.size)) + .collect(); + let field_size = type_size_with(field_type, &struct_sizes); + self.var_allocations.push(VarAllocation { + name: full_name, + address: field_addr, + size: field_size, + }); + } + } + } + } + fn register_var(&mut self, var: &VarDecl) { if self.symbols.contains_key(&var.name) { self.diagnostics.push(Diagnostic::error( @@ -991,37 +1097,19 @@ impl Analyzer { return; }; - // For struct-typed variables, synthesize per-field entries in - // the symbol table and var_allocations. This lets the rest of - // the compiler treat `pos.x` and `pos.y` as ordinary variables - // at known addresses, without special-casing struct layout. + // For struct-typed variables, synthesize per-field entries + // in the symbol table and var_allocations. This lets the + // rest of the compiler treat `pos.x` and `pos.y` as + // ordinary variables at known addresses, without special- + // casing struct layout. Nested structs recurse — a + // `Player { pos: Point, ... }` variable produces both + // `p.pos` (typed `Struct("Point")`) and `p.pos.x`, + // `p.pos.y` leaves. Array fields produce a single + // synthetic with the array type so the existing + // `Expr::ArrayIndex` lowering picks them up. if let NesType::Struct(sname) = &var.var_type { let layout = self.struct_layouts[sname].clone(); - for (field_name, field_type, offset) in &layout.fields { - let full_name = format!("{}.{field_name}", var.name); - self.symbols.insert( - full_name.clone(), - Symbol { - name: full_name.clone(), - sym_type: field_type.clone(), - is_const: false, - span: var.span, - }, - ); - // u16 struct fields occupy two bytes — record that - // explicitly so the IR codegen's global-init pass and - // any size-aware bookkeeping treat the high byte as - // part of the same allocation. - let field_size = match field_type { - NesType::U16 => 2, - _ => 1, - }; - self.var_allocations.push(VarAllocation { - name: full_name, - address: address + offset, - size: field_size, - }); - } + self.flatten_struct_fields(&var.name, address, &layout, var.span); // Also register the struct variable itself (as a symbol // only — it doesn't have a single VarAllocation entry). self.symbols.insert( diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index eb4d2cb..0cd09fd 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -371,32 +371,113 @@ fn analyze_struct_u16_field_allocates_two_bytes() { } #[test] -fn analyze_struct_with_array_field_is_rejected() { - // Array fields are still rejected — the analyzer only accepts - // u8/i8/u16/bool scalar fields in v1 structs. - let errors = analyze_errors( +fn analyze_struct_with_array_field_is_supported() { + // Array struct fields are supported. The analyzer flattens + // them into a single synthetic var typed `Array(u8, 4)` so + // the existing array-index codegen lowers `b.xs[i]` exactly + // like a top-level array. + let result = analyze_ok( r#" game "Test" { mapper: NROM } struct Bag { xs: u8[4] } var b: Bag - on frame { wait_frame } + on frame { + b.xs[0] = 7 + wait_frame + } start Main "#, ); - assert!( - errors.contains(&ErrorCode::E0201), - "array struct field should emit E0201: {errors:?}" - ); + let alloc = result + .var_allocations + .iter() + .find(|a| a.name == "b.xs") + .expect("expected synthetic `b.xs` allocation"); + assert_eq!(alloc.size, 4, "u8[4] should reserve 4 bytes"); + let sym = result + .symbols + .get("b.xs") + .expect("expected symbol entry for `b.xs`"); + assert!(matches!(sym.sym_type, NesType::Array(_, 4))); } #[test] -fn analyze_struct_with_nested_struct_field_is_rejected() { - // Nested struct fields are still rejected — only scalar primitives. +fn analyze_struct_with_nested_struct_field_is_supported() { + // Nested struct fields are flattened recursively. A + // `Player { pos: Point, hp: u8 }` variable produces both + // `p.pos.x` / `p.pos.y` leaves and an intermediate + // `p.pos` Struct symbol. + let result = analyze_ok( + r#" + game "Test" { mapper: NROM } + struct Point { x: u8, y: u8 } + struct Player { pos: Point, hp: u8 } + var p: Player + on frame { + p.pos.x = 5 + p.pos.y = 6 + p.hp = 100 + wait_frame + } + start Main + "#, + ); + // Each leaf field gets its own allocation entry. + assert!(result.var_allocations.iter().any(|a| a.name == "p.pos.x")); + assert!(result.var_allocations.iter().any(|a| a.name == "p.pos.y")); + assert!(result.var_allocations.iter().any(|a| a.name == "p.hp")); + // The intermediate `p.pos` is a Struct symbol but has no + // standalone allocation — its bytes are owned by the leaves. + let pos = result + .symbols + .get("p.pos") + .expect("intermediate `p.pos` should exist as a symbol"); + assert!(matches!(pos.sym_type, NesType::Struct(_))); + assert!(result.var_allocations.iter().all(|a| a.name != "p.pos")); +} + +#[test] +fn analyze_struct_with_nested_struct_field_addresses_are_contiguous() { + // The four leaf fields of a `Player { pos: Point, hp: u8, + // inv: u8[4] }` should land at successive addresses with no + // padding — Point.x at base, Point.y at base+1, hp at base+2, + // inv at base+3..base+6. + let result = analyze_ok( + r#" + game "Test" { mapper: NROM } + struct Point { x: u8, y: u8 } + struct Player { pos: Point, hp: u8, inv: u8[4] } + var p: Player + on frame { + p.pos.x = 1 + wait_frame + } + start Main + "#, + ); + let alloc = |name: &str| { + result + .var_allocations + .iter() + .find(|a| a.name == name) + .unwrap_or_else(|| panic!("missing allocation: {name}")) + .address + }; + let base = alloc("p.pos.x"); + assert_eq!(alloc("p.pos.y"), base + 1); + assert_eq!(alloc("p.hp"), base + 2); + assert_eq!(alloc("p.inv"), base + 3); +} + +#[test] +fn analyze_struct_with_unknown_inner_struct_errors() { + // A nested-struct field that references an undeclared inner + // struct must emit E0201 with a "declare it earlier" hint. + // (We don't topologically sort declarations.) let errors = analyze_errors( r#" game "Test" { mapper: NROM } - struct Inner { a: u8 } - struct Outer { inner: Inner } + struct Outer { inner: NotDeclared } var o: Outer on frame { wait_frame } start Main @@ -404,7 +485,29 @@ fn analyze_struct_with_nested_struct_field_is_rejected() { ); assert!( errors.contains(&ErrorCode::E0201), - "nested struct field should emit E0201: {errors:?}" + "expected E0201, got: {errors:?}" + ); +} + +#[test] +fn analyze_struct_with_array_of_structs_is_rejected() { + // Arrays of structs aren't supported yet — the synthetic- + // variable model can't index into per-element struct layouts + // without additional codegen work. Make sure it errors + // cleanly with E0201 instead of producing a broken layout. + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + struct Point { x: u8, y: u8 } + struct Cluster { points: Point[4] } + var c: Cluster + on frame { wait_frame } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "expected E0201 for array-of-structs, got: {errors:?}" ); } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 2a4313e..2373c76 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -124,6 +124,72 @@ impl LoweringContext { } } + /// Recursively expand a struct-literal global initializer into + /// per-leaf-field `IrGlobal` entries. Handles three field-value + /// shapes: + /// + /// - Scalar constant expressions (e.g. `x: 5`) → emit one + /// `IrGlobal` whose `init_value` is the folded constant. + /// - Nested struct literals (e.g. `pos: Vec2 { x: 1, y: 2 }`) + /// → recurse with `base_name = "outer.pos"`, expanding the + /// inner literal's fields under the dotted path. + /// - Array literals (e.g. `inv: [1, 2, 3, 4]`) → emit one + /// `IrGlobal` whose `init_array` carries the per-byte values. + /// + /// Each leaf global's size is derived from the analyzer's + /// recorded field type so `u16` fields still claim two bytes. + fn expand_struct_literal_init(&mut self, base_name: &str, fields: &[(String, Expr)]) { + for (fname, fexpr) in fields { + let full = format!("{base_name}.{fname}"); + let fvid = self.get_or_create_var(&full); + let field_type = self.var_types.get(&full).cloned(); + match fexpr { + Expr::StructLiteral(_, inner_fields, _) => { + // Register the intermediate symbol with size 0 — + // its byte-allocation lives in the leaves, but + // the IR codegen still needs a global record so + // that name lookups don't fail. + self.globals.push(IrGlobal { + var_id: fvid, + name: full.clone(), + size: 0, + init_value: None, + init_array: Vec::new(), + }); + self.expand_struct_literal_init(&full, inner_fields); + } + Expr::ArrayLiteral(elems, _) => { + let init_array: Vec = elems + .iter() + .filter_map(|e| self.eval_const(e).map(|v| v as u8)) + .collect(); + let size = type_size(field_type.as_ref().unwrap_or(&NesType::U8)); + self.globals.push(IrGlobal { + var_id: fvid, + name: full, + size, + init_value: None, + init_array, + }); + } + _ => { + let fval = self.eval_const(fexpr); + let size = match field_type { + Some(NesType::U16) => 2, + _ => 1, + }; + self.globals.push(IrGlobal { + var_id: fvid, + name: full, + size, + init_value: fval, + init_array: Vec::new(), + }); + } + } + } + } + /// Try to evaluate an expression at compile time, using the /// already-registered constants as operands. Returns `None` if /// the expression references something that isn't known at this @@ -226,7 +292,10 @@ impl LoweringContext { // op referencing it by name still resolves. Array-literal // initializers are lowered into `init_array` on the parent // global — the IR codegen's startup loop emits one LDA/STA - // per byte into the global's base address. + // per byte into the global's base address. Nested struct + // literals (`Player { pos: Vec2 { x: 1, y: 2 }, ... }`) + // and array-literal field values (`Hero { inv: [1,2,3,4] }`) + // are expanded recursively below. for var in &program.globals { let var_id = self.get_or_create_var(&var.name); let init = var.init.as_ref().and_then(|e| self.eval_const(e)); @@ -245,26 +314,7 @@ impl LoweringContext { init_array, }); if let Some(Expr::StructLiteral(_, fields, _)) = &var.init { - for (fname, fexpr) in fields { - let full = format!("{}.{fname}", var.name); - let fvid = self.get_or_create_var(&full); - let fval = self.eval_const(fexpr); - // Look up the field's type from the analyzer's - // symbol table so u16 fields record a size of 2 - // and the IR codegen's initializer loop writes - // both bytes. - let field_size = match self.var_types.get(&full) { - Some(NesType::U16) => 2, - _ => 1, - }; - self.globals.push(IrGlobal { - var_id: fvid, - name: full, - size: field_size, - init_value: fval, - init_array: Vec::new(), - }); - } + self.expand_struct_literal_init(&var.name, fields); } } diff --git a/src/ir/tests.rs b/src/ir/tests.rs index b2154eb..83706c5 100644 --- a/src/ir/tests.rs +++ b/src/ir/tests.rs @@ -350,6 +350,45 @@ fn lower_debug_frame_overrun_count_emits_peek() { ); } +#[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] }` + // initializer must produce one IrGlobal per leaf field with the + // right scalar `init_value` / per-element `init_array`. The + // intermediate `hero.pos` is registered with `size: 0` so name + // lookups still work but no separate init bytes are emitted. + let ir = lower_ok( + r#" + game "T" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + struct Hero { pos: Vec2, hp: u8, inv: u8[4] } + var hero: Hero = Hero { pos: Vec2 { x: 1, y: 2 }, hp: 100, inv: [3, 4, 5, 6] } + on frame { wait_frame } + start Main + "#, + ); + let by_name = |n: &str| { + ir.globals + .iter() + .find(|g| g.name == n) + .unwrap_or_else(|| panic!("missing global: {n}")) + }; + let pos_x = by_name("hero.pos.x"); + let pos_y = by_name("hero.pos.y"); + let hp = by_name("hero.hp"); + let inv = by_name("hero.inv"); + assert_eq!(pos_x.init_value, Some(1)); + assert_eq!(pos_y.init_value, Some(2)); + assert_eq!(hp.init_value, Some(100)); + assert_eq!(inv.init_array, vec![3, 4, 5, 6]); + // The intermediate must exist with size 0 so codegen can + // resolve `hero.pos` lookups even though it carries no bytes. + let pos = by_name("hero.pos"); + assert_eq!(pos.size, 0); + assert_eq!(pos.init_value, None); + assert!(pos.init_array.is_empty()); +} + #[test] fn lower_debug_frame_overran_emits_peek_07fe() { let ir = lower_ok( diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 1bc64d1..1816a9d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2691,13 +2691,38 @@ impl Parser { )) } TokenKind::Dot => { - // Field assignment: name.field = value - self.advance(); - let (field, _) = self.expect_ident()?; + // Field assignment: `name.field = value`, including + // chains (`outer.inner.field = value`) and array + // fields (`name.inv[idx] = value`). The dotted + // chain is eagerly joined into a single synthetic + // identifier so the analyzer/lowering can treat it + // identically to a flat variable name. + let mut chain = vec![name]; + while *self.peek() == TokenKind::Dot { + self.advance(); + let (field, _) = self.expect_ident()?; + chain.push(field); + } + if *self.peek() == TokenKind::LBracket { + self.advance(); + let index = self.parse_expr()?; + self.expect(&TokenKind::RBracket)?; + let op = self.parse_assign_op()?; + let value = self.parse_expr()?; + let joined = chain.join("."); + return Ok(Statement::Assign( + LValue::ArrayIndex(joined, Box::new(index)), + op, + value, + start, + )); + } + let last = chain.pop().expect("at least one field consumed"); + let base = chain.join("."); let op = self.parse_assign_op()?; let value = self.parse_expr()?; Ok(Statement::Assign( - LValue::Field(name, field), + LValue::Field(base, last), op, value, start, @@ -3072,11 +3097,36 @@ impl Parser { return Ok(Expr::Call(name, args, span)); } - // Check for field access: `name.field` + // Check for field access: `name.field`, including + // chains like `outer.inner.field` for nested + // structs and `name.field[idx]` for array fields. + // The synthetic flat-name model used by the + // analyzer/lowering keys symbols by the joined + // dotted path, so we eagerly consume the entire + // chain into a single identifier string. if *self.peek() == TokenKind::Dot { - self.advance(); - let (field, _) = self.expect_ident()?; - return Ok(Expr::FieldAccess(name, field, span)); + let mut chain = vec![name]; + while *self.peek() == TokenKind::Dot { + self.advance(); + let (field, _) = self.expect_ident()?; + chain.push(field); + } + // After the dotted chain we may still see an + // array index — `player.inv[0]` becomes + // `ArrayIndex("player.inv", idx)`. Otherwise + // the last segment is the field of the path + // before it: `p.pos.x` becomes + // `FieldAccess("p.pos", "x")`. + if *self.peek() == TokenKind::LBracket { + self.advance(); + let index = self.parse_expr()?; + self.expect(&TokenKind::RBracket)?; + let joined = chain.join("."); + return Ok(Expr::ArrayIndex(joined, Box::new(index), span)); + } + let last = chain.pop().expect("at least one field consumed"); + let base = chain.join("."); + return Ok(Expr::FieldAccess(base, last, span)); } // Check for struct literal: `Name { field: expr, ... }`. diff --git a/tests/emulator/goldens/nested_structs.audio.hash b/tests/emulator/goldens/nested_structs.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/nested_structs.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/nested_structs.png b/tests/emulator/goldens/nested_structs.png new file mode 100644 index 0000000000000000000000000000000000000000..440be38d51809ef29e9232817af9087e791ebb8f GIT binary patch literal 951 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQuJd$p45?szdvIg#9X1A* z16Q3GFRy+Wwd0?50{5CmO=C%Uj(`;FtMAYJ@%W^DUocEifI@G(q;lSY{h2?+fMWUIOWFSQx8*%WD6*w+C$ z`_4gK{h{9E&(*%iCxBX~gF|E=P(tRo;16^ANq@NN_dc)uwEVFJ)BmRwdtf W6Z7(K%I&HK