mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
analyzer/lowering: support nested struct fields and array struct fields
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
This commit is contained in:
parent
d4e613fb7c
commit
7294ae3efa
12 changed files with 478 additions and 90 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
examples/nested_structs.ne
Normal file
54
examples/nested_structs.ne
Normal file
|
|
@ -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
|
||||
BIN
examples/nested_structs.nes
Normal file
BIN
examples/nested_structs.nes
Normal file
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String, u16> = 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<String, u16> = 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(
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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, ... }`.
|
||||
|
|
|
|||
1
tests/emulator/goldens/nested_structs.audio.hash
Normal file
1
tests/emulator/goldens/nested_structs.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/nested_structs.png
Normal file
BIN
tests/emulator/goldens/nested_structs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 951 B |
Loading…
Add table
Add a link
Reference in a new issue