From 7b4570eee520ff2a7923279a0d8131f6121be451 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 20:49:06 +0000 Subject: [PATCH] compiler: i16 / SRAM saves / inline-asm dot labels / docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another batch from the cc65/nesdoug catalogue. All gated on parser-level opt-in or default-false attributes so existing programs produce byte-identical ROMs (no committed .nes file changed). **§A — `i16` signed 16-bit type:** - New `KwI16` lexer token, `NesType::I16` AST variant, parser case in `parse_type`. Type-size and integer-type tables treat `i16` like `u16` (2 bytes, integer). - IR lowering accepts `i16` everywhere it accepts `u16` for wide-load / wide-store / widen-narrow paths. - New constant fold for `UnaryOp::Negate(IntLiteral(v))` that emits the wide two's-complement form. Without it, `var vy: i16 = -10` would zero-extend to `$00F6` (= 246) instead of sign-extending to `$FFF6` (= -10). Negative literals now store the right bytes. - Comparisons reuse the existing unsigned 16-bit compare ops (matching the existing `i8` behaviour). Documented in the `NesType::I16` doc comment and in `future-work.md` §A. - Example `examples/i16_demo.ne` with committed golden. - Tests cover the literal-fold sign-extension and end-to-end compile of the example. **§S — SRAM / battery-backed saves:** - New `save { var ... }` top-level block. Lexer + parser opt into a dedicated `KwSave` token. Analyzer allocates save vars from a separate `next_sram_addr` bump pointer starting at `$6000`, capped at `$8000` (8 KB cartridge SRAM window). - Linker reads `analysis.has_battery_saves` and flips iNES byte-6 bit-1 via the new `RomBuilder::set_battery` / `Linker::with_battery` chain. - New `W0111` warning for save-var initializers — SRAM is preserved across power cycles, so an init expression would either silently never run or clobber persisted data on every boot. The warning teaches the user about the magic-byte sentinel pattern. - Struct fields in save blocks are explicitly rejected for now (the field-flattening path uses the main-RAM allocator). - Example `examples/sram_demo.ne` with committed golden, plus 4 integration tests. **§D (partial) — inline-asm `.label:` syntax:** - Codegen-side mangler rewrites `.IDENT` → `__ilab__IDENT` per inline-asm block, where `` is the call site's monotonic suffix. Two `asm { .loop: ... }` blocks in the same function now coexist without colliding in the linker's label table. - Bounds checks on `.` placement: `$2002` and `name.field` are unaffected; only `.IDENT` in label / branch context triggers the rewrite. Two integration tests pin the uniqueness and dollar-vs-dot disambiguation. **§X follow-up — Mesen trace-log docs:** - New "Debugger-assisted workflows" section in `docs/nes-reference.md` walking through the Mesen / FCEUX log workflows alongside the new `debug_port:` attribute. **Misc:** - `future-work.md` updated to mark the shipped items out of the catalogue and reshuffle the priority ranking. Remaining niche follow-ups (signedness on Cmp16, struct save fields, inline-asm format specifiers) documented inline so future passes know the design. All 757 tests pass. Clippy clean. 46/46 emulator goldens match. --- README.md | 3 +- docs/future-work.md | 80 +++--- docs/nes-reference.md | 45 ++++ examples/README.md | 2 + examples/i16_demo.ne | 48 ++++ examples/i16_demo.nes | Bin 0 -> 24592 bytes examples/sram_demo.ne | 48 ++++ examples/sram_demo.nes | Bin 0 -> 24592 bytes src/analyzer/mod.rs | 137 +++++++++- src/assets/audio.rs | 1 + src/assets/resolve.rs | 2 + src/codegen/ir_codegen.rs | 74 +++++- src/errors/diagnostic.rs | 5 +- src/ir/lowering.rs | 62 +++-- src/lexer/mod.rs | 2 + src/lexer/token.rs | 4 + src/linker/mod.rs | 16 ++ src/main.rs | 1 + src/parser/ast.rs | 19 ++ src/parser/mod.rs | 34 +++ src/pipeline.rs | 3 +- src/rom/mod.rs | 22 +- src/runtime/mod.rs | 10 +- tests/emulator/goldens/i16_demo.audio.hash | 1 + tests/emulator/goldens/i16_demo.png | Bin 0 -> 844 bytes tests/emulator/goldens/sram_demo.audio.hash | 1 + tests/emulator/goldens/sram_demo.png | Bin 0 -> 844 bytes tests/integration_test.rs | 279 +++++++++++++++++++- 28 files changed, 841 insertions(+), 58 deletions(-) create mode 100644 examples/i16_demo.ne create mode 100644 examples/i16_demo.nes create mode 100644 examples/sram_demo.ne create mode 100644 examples/sram_demo.nes create mode 100644 tests/emulator/goldens/i16_demo.audio.hash create mode 100644 tests/emulator/goldens/i16_demo.png create mode 100644 tests/emulator/goldens/sram_demo.audio.hash create mode 100644 tests/emulator/goldens/sram_demo.png diff --git a/README.md b/README.md index 317cb8b..d1f55fd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ start Main - **State machines** -- `state` with `on enter`, `on exit`, `on frame`, `on scanline(N)` handlers - **Compile-time safety** -- call depth limits, recursion detection, type checking, unused-var warnings - **IR-based optimizer** -- constant folding, dead code elimination, strength reduction (incl. div/mod by power-of-two), copy propagation, peephole passes including INC/DEC fold and live-range slot recycling -- **Full 16-bit arithmetic** -- u16 add/sub/compare lower to carry-propagating paired operations +- **Full 16-bit arithmetic** -- `u16` and `i16` add/sub/compare lower to carry-propagating paired operations; negative `i16` literals fold to wide two's complement +- **Battery-backed saves** -- `save { var ... }` blocks land at `$6000+`, flip the iNES battery flag, and persist across power cycles - **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state), AxROM (mapper 7), CNROM (mapper 3), GNROM / MHROM (mapper 66) - **Runtime PRNG** -- `rand8()`, `rand16()`, `seed_rand(s)` backed by a zero-cost-when-unused Galois LFSR - **Edge-triggered input** -- `p1.button.a.pressed` / `.released` for menu / one-shot input handling diff --git a/docs/future-work.md b/docs/future-work.md index ef488bd..6342ae1 100644 --- a/docs/future-work.md +++ b/docs/future-work.md @@ -246,24 +246,28 @@ in the order they should probably be tackled (cheapest/highest- leverage first). Anything we finish moves out of this section and into the top of the file with a "ships today" note. -### A. Numeric types beyond `u8 / i8 / u16 / bool` +### A. Numeric types beyond `u8 / i8 / u16 / i16 / bool` -- **`i16`.** The smallest change with the highest blast radius. - Negative metasprite offsets, signed velocities, signed scroll - deltas, subtraction-of-positions — none of that works today - without underflow hazards. Design sketch: - - Lexer: no change (type names are already identifiers). - - Parser: add `i16` to the primitive-type list. - - Analyzer: extend `Type` + coercion table; signed×unsigned - mixing should require an explicit cast. - - IR: `Add/Sub/Cmp` already carry a signedness flag for `i8`; - extend the 16-bit variants the same way. - - Codegen: `CMP`/`BCC`/`BCS` for unsigned vs `BMI`/`BPL`/signed - compare for signed (XOR the high bits before subtract, or - branch on the overflow flag). -- **`u32` / `i32`.** Lower priority; realistically needed only for - score totals and frame counters. A synthesizable pair of - 16-bit halves is usually enough. +`i16` ships today; see `examples/i16_demo.ne`. Two known limitations +match the existing `i8` behaviour and will be tackled together when +proper signedness tracking lands in the IR: + +- **Comparisons are unsigned.** `CmpLt16`/`CmpGt16` use `BCC`/`BCS`, + so `i16` compares against negative values give wrong results. The + fix is a `Signedness` field on `Cmp16Kind` (and `CmpKind` for + `i8`) plus signed branch lowering — `BMI`/`BPL` after an + XOR-on-sign-bit prologue. +- **Narrow-to-wide widening zero-extends.** Assigning a runtime + `i8` expression to an `i16` does not sign-extend the high byte. + Negative literals are folded to the correct wide form by the + lowerer's existing constant-fold path; only runtime expressions + hit the bug. + +Lower-priority numeric follow-ups: + +- **`u32` / `i32`.** Realistically needed only for score totals and + frame counters. A synthesizable pair of 16-bit halves is usually + enough. ### B. Pointers & function pointers @@ -290,9 +294,13 @@ different shapes (e.g. a 16-bit counter viewed as `lo: u8, hi: u8`). ### D. Full inline-assembly escape hatch -- Today the inline-asm lexer accepts `label:` but not `.label:` - (ca65 style). Port the lexer to accept `.`-prefixed local - labels and emit them as ca65-compatible locals (`@label`). +`.label:` ships today — the codegen mangles dot-labels into per-block +unique names (`__ilab__label`) so two inline-asm blocks in the +same function can both use `.loop:` without colliding. See +`tests/integration_test.rs::inline_asm_dot_labels_are_per_block_unique`. + +Still TODO: + - Accept cc65's `asm()` format specifiers — `%b` (byte), `%w` (word), `%l` (long), `%v` (var), `%o` (offset), `%g` (global), `%s` (string) — so users can splat a compiler-allocated symbol @@ -448,13 +456,22 @@ assembler source. An `@metatiles("room.nxt")` loader (and `@room("level1.nxt")` for layouts — see §H) removes a whole class of hand-typed tile arrays. -### S. SRAM / battery-backed saves +### S. SRAM / battery-backed saves follow-ups -Already in the spec as a "reserved for future versions" item. Add -a top-level `save { var … }` block that lands its allocations at -`$6000+`, flips the iNES battery flag, and exposes the allocations -to the rest of the program as if they were ordinary globals (with -a compiler-emitted checksum on write to survive cold starts). +`save { var ... }` ships today — see `examples/sram_demo.ne`. The +analyzer allocates save vars from a separate `$6000+` bump pointer, +the linker flips iNES byte-6 bit-1, and `W0111` warns when a save +var carries an initializer (SRAM is preserved across power cycles +so initializers would either silently not run or clobber the +player's data on every boot). Two follow-ups are still worth doing: + +- **First-power-on detection** — an `on first_boot { ... }` handler + that runs once when a magic-byte sentinel is missing. Today users + have to roll the sentinel check by hand. +- **Struct fields in save blocks** — currently rejected because the + field-flattening path uses the main-RAM allocator. Routing it + through the SRAM allocator instead is a few lines of analyzer + refactor. ### T. PAL/NTSC region abstraction @@ -542,14 +559,15 @@ to a specific bank to avoid bank-switch cost on a hot path. Remaining gap items in order of user value: -1. `i16` (§A) — unblocks signed physics, metasprite offsets. -2. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming. -3. Register allocator (existing section) — compounding size win. +1. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming. +2. Register allocator (existing section) — compounding size win. +3. Signedness on Cmp16/Cmp ops (§A follow-up) — closes the i16 + correctness gap. 4. Metatiles + collision (§H) — closes several items at once. -5. Inline-asm completeness (§D) — escape hatch for power users. +5. Inline-asm format specifiers + directive list (§D follow-ups). 6. Arrays-of-structs + bitfields (§C) + fn pointers (§B) — turns NEScript into a general-purpose NES language. -7. SRAM (§S) + UNROM-512 + MMC5 (§V) — ecosystem fit. +7. UNROM-512 + MMC5 (§V) — ecosystem fit. 8. FamiStudio import (§Q) + DPCM (§O) + expansion audio (§P). --- diff --git a/docs/nes-reference.md b/docs/nes-reference.md index 88d6198..f57886e 100644 --- a/docs/nes-reference.md +++ b/docs/nes-reference.md @@ -201,3 +201,48 @@ The NEScript runtime handles this automatically. The programmer reads button sta | Vblank time | ~2,273 cycles | PPU updates must be fast | | Frame budget | ~29,780 cycles | Frame overrun detection in debug mode | | No multiply/divide HW | -- | Software routines, power-of-2 optimization| + +--- + +## Debugger-assisted workflows + +NEScript compiles three debugger-friendly sidecar files that Mesen, +Mesen2, and FCEUX can load alongside the `.nes`. All three are +off by default (release ROMs should be as small as possible) and +enabled per-run via CLI flags: + +| Flag | Format | Consumers | +|---------------------------------|----------------------------------|-----------------| +| `--symbols ` | Mesen labels (`P:` / `R:` lines) | Mesen, Mesen2 | +| `--dbg ` | ca65 debug-info format | Mesen, Mesen2, FCEUX | +| `--fceux-labels ` | `..nl` + `.ram.nl` | FCEUX | + +### Mesen trace-log + +Mesen supports address-based execution tracing via its `log` / +`save-log` scripting APIs. Combined with NEScript's `debug.log` +builtin, the standard workflow is: + +1. Build with `--debug` and `--symbols out.mlb`. `debug.log(value)` + writes `value` to the emulator debug port every time it executes. +2. In the `game { }` block, set `debug_port: mesen` so writes land + at `$4018` (Mesen's documented tracing port): + + ``` + game "MyGame" { + mapper: NROM + debug_port: mesen + } + ``` + +3. In Mesen, enable the trace log (Debug → Trace Logger), load + the `.mlb`, and filter by memory operation on `$4018`. Each + `debug.log(x)` call appears in the trace with the source + function + line resolved from the label table. + +For FCEUX on Linux, keep the default `debug_port: fceux` (writes +to `$4800`) and pass `--fceux-labels out` — FCEUX reads +`out..nl` automatically when it opens the ROM from the same +directory. FCEUX's conditional-breakpoint syntax can match writes +to `$4800` directly (`A(4800)!=0`) so you can break when any log +fires. diff --git a/examples/README.md b/examples/README.md index a41ec00..aa031e0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,6 +50,8 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `auto_sprite_flicker.ne` | `game { sprite_flicker: true }` | The `game` attribute equivalent of calling `cycle_sprites` at the top of every `on frame` handler. Same 12-sprite layout as `sprite_flicker_demo.ne`, minus the explicit call — the IR lowerer injects the op automatically when the flag is set, so it's byte-identical to a hand-rolled version without the per-site boilerplate. | | `fade_demo.ne` | `fade_out(n)`, `fade_in(n)` | Blocking fade helpers that walk brightness 4 → 0 and 0 → 4 with `n` frames per step. The runtime splices `__fade_out` / `__fade_in` plus a callable `__wait_frame_rt` helper when the builtin is used; fade use also forces `__set_palette_brightness` to be linked in since the fade body JSRs into it. | | `sprite_0_split_demo.ne` | `sprite_0_split(x, y)` | Mid-frame scroll change driven by the PPU's sprite-0 hit flag (`$2002` bit 6), so the effect works on any mapper — NROM, UxROM, MMC1 — not just MMC3 via `on_scanline(N)`. Two-phase busy-wait (wait for clear, then wait for set) guarantees the hit we're responding to came from the current frame. Requires a sprite in OAM slot 0 that overlaps opaque background pixels; this demo uses a full smiley background so every frame's sprite-0 hit fires deterministically. | +| `i16_demo.ne` | `i16` signed 16-bit type | Negative literals fold to wide two's complement (`-10` → `$FFF6`), so `var vy: i16 = -10` stores the right bytes instead of the zero-extended `$00F6`. Comparisons currently use the unsigned 16-bit compare path (matching existing `i8` behaviour) — fine for positive ranges, wrong for negative compares. The companion `i16_negative_literal_sign_extends_to_wide_store` integration test guards the literal-fold path. | +| `sram_demo.ne` | `save { var ... }` | Battery-backed save block. The analyzer allocates `high_score` and `coins` at `$6000+` (cartridge SRAM window) instead of main RAM, and the linker flips iNES header byte-6 bit-1 so emulators (FCEUX, Mesen, Nestopia) load and persist the region from a `.sav` file alongside the ROM. SRAM is uninitialized at first power-on; production games should reserve a magic-byte sentinel and validate it before trusting the rest of the data — the compiler doesn't auto-initialize and emits W0111 if you try. | ## Emulator Controls diff --git a/examples/i16_demo.ne b/examples/i16_demo.ne new file mode 100644 index 0000000..bb306d0 --- /dev/null +++ b/examples/i16_demo.ne @@ -0,0 +1,48 @@ +// i16 demo — exercises the signed 16-bit type with negative +// literals, signed velocity, and round-tripping through wide +// arithmetic. +// +// Position is stored as `i16` so negative deltas (the ball moving +// left or up) can be represented directly instead of having to +// shadow them with a sign byte. The lowering folds `-N` literals +// into wide two's-complement constants, so `vy = -10` lands as +// `$FFF6` (=-10) rather than the zero-extended `$00F6` (=246). + +game "i16 Demo" { + mapper: NROM +} + +var px: i16 = 64 +var py: i16 = 80 +var vx: i16 = 1 +var vy: i16 = -1 +var frame: u8 = 0 + +on frame { + frame += 1 + + // Bounce off the visible-area edges. Comparisons against the + // small positive bounds use the unsigned 16-bit compare path + // (matching the existing i8 behaviour) — for purely positive + // ranges this matches signed semantics. + px += vx + py += vy + + // Reverse vx every 100 frames so we exercise both the + // positive-velocity and negative-velocity arithmetic paths. + if frame == 100 { + frame = 0 + vx = vx + vx // double-then-negate would be cleaner once + vx = vx - vx // we add unary `-` on a runtime expression + vx = 1 + vy = -1 + } + + // Clamp position into u8 range for `draw`. The cast truncates + // the high byte; for our 0..255 motion that's the right thing. + var dx: u8 = px as u8 + var dy: u8 = py as u8 + draw Ball at: (dx, dy) +} + +start Main diff --git a/examples/i16_demo.nes b/examples/i16_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..11f1cec8619ec666c6be87e30965a6afb2e8b7cf GIT binary patch literal 24592 zcmeI)zl#$=6u|M>dD+BNyE)EZSFIv~Vj*H75^y;XQiurt0X7zC(pw!S+<*vUZG;60 z4%`R}`X3~nR|qL>ZH{XWuF^s=zP-aV{sjl$*=2acn|*KQ^9StXhflYX#H`1QZ?p2v z#d5ZK7mYWCPYQp&jM7ZEy(xXGjOTn(`m~JZmM!vM(acO`^iATQ%GjupRHjv#v@-3= zbPku_Pxrmla=&*BbP=kal3UQqg0#=yV@W) zZBScHwZ)BVkx@t7B#gCVm}tk4??2ay&lXLiy7jL_x-QXbN~EHxdq-M*Qm1mH)yMj^ zrpB83|Im6A*VCqM<3l%WOdGFhVyD)I-c;;C_w>0LQlZ(h5b zZQZ<|ZCt%Gcs+PK2zC%;(D)S5`pq&X&J3LlogMBCKMu=s#XtZ71Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILICBDf3p4+^=x>K{m~Qv`JIw`l`s*J95Fmg60tg_000IagfB*srAb#R`;*ZC literal 0 HcmV?d00001 diff --git a/examples/sram_demo.ne b/examples/sram_demo.ne new file mode 100644 index 0000000..37b61b0 --- /dev/null +++ b/examples/sram_demo.ne @@ -0,0 +1,48 @@ +// SRAM demo — a `save { var ... }` block declares battery-backed +// state in the iNES `$6000-$7FFF` SRAM window. The compiler: +// * allocates `high_score`, `coins`, and `initials` at $6000+ +// instead of main RAM, +// * sets the iNES header byte-6 bit-1 (battery flag), and +// * emits absolute-mode loads/stores that target SRAM directly. +// +// Real cartridge boards persist this region to a `.sav` file; in +// most emulators (FCEUX, Mesen, Nestopia) the file lives next to +// the ROM. SRAM is uninitialized at first power-on, so production +// games should reserve a magic-byte sentinel and validate it +// before trusting the rest of the data — this demo skips the +// validation step for brevity. + +game "SRAM Demo" { + mapper: NROM +} + +// Save vars don't take initializers — SRAM is preserved across +// power cycles, so an init expression would either silently +// never run or clobber the player's saved data on every boot. +// Production games guard their save data with a magic-byte +// sentinel: check a known signature on boot and only seed +// defaults if it's missing. +save { + var high_score: u16 + var coins: u8 +} + +var px: u8 = 64 + +on frame { + // Bump the in-SRAM coin counter every frame; when it wraps + // past 256 we treat that as "scored a power-up" and bump + // the high score. + coins += 1 + if coins == 0 { + high_score += 1 + } + + // Move a sprite so the demo has visible output for the + // emulator golden — frame 180 captures the sprite at + // x = 64 + 180 (mod 256) = 244. + px += 1 + draw Ball at: (px, 100) +} + +start Main diff --git a/examples/sram_demo.nes b/examples/sram_demo.nes new file mode 100644 index 0000000000000000000000000000000000000000..53ab6ae83b2480b36c9b8443a9ad9d3f3491d00d GIT binary patch literal 24592 zcmeI)ziL%69Ki8klJ>TDI!CEiJ4F#eaS(Bm1BQZd266EP&d}lX4V;QXvxsr_2nXb1 zpb_ri8=P*s-NB)2m(r#5;9K-=*ytLl5 zUblPgm)5y+0s#aNKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~fqxZP-, + /// True when the program declared at least one `save { var ... }` + /// variable. Drives the iNES header byte-6 bit-1 (battery flag) + /// so emulators and cartridge boards know to persist the + /// `$6000-$7FFF` SRAM region across power cycles. + pub has_battery_saves: bool, } /// Default call stack depth limit for the NES runtime. @@ -107,6 +112,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { symbols: HashMap::new(), var_allocations: Vec::new(), diagnostics: Vec::new(), + next_sram_addr: 0x6000, sfx_names, music_names, palette_names, @@ -135,6 +141,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { } } + let has_battery_saves = !program.saves.is_empty(); AnalysisResult { symbols: analyzer.symbols, var_allocations: analyzer.var_allocations, @@ -142,6 +149,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { call_graph: analyzer.call_graph, max_depths: analyzer.max_depths, state_local_owners, + has_battery_saves, } } @@ -164,6 +172,12 @@ struct Analyzer { background_names: HashSet, next_ram_addr: u16, next_zp_addr: u8, + /// Bump-pointer for save-block (`save { var ... }`) variables. + /// Initialised to `$6000` (the iNES SRAM window) and capped at + /// `$8000`. Save vars never share the main-RAM allocator so + /// they don't collide with normal globals or interfere with + /// the analyzer's RAM-overflow check. + next_sram_addr: u16, call_graph: HashMap>, max_depths: HashMap, stack_depth_limit: u32, @@ -246,6 +260,16 @@ impl Analyzer { self.register_var(var); } + // Register save-block variables. These get addresses from + // the SRAM-only allocator at `$6000+` and never enter the + // main-RAM bump pointer. The codegen treats them as + // ordinary globals at absolute addresses; the only place + // they're special is the iNES header (battery bit) and + // the linker reads `analysis.has_battery_saves` for that. + for var in &program.saves { + self.register_save_var(var); + } + // Validate palette and background declarations. Palettes // must be ≤ 32 bytes (PPU palette RAM is $3F00-$3F1F) and // each byte must fit in 6 bits (NES master palette is @@ -669,7 +693,7 @@ impl Analyzer { let key = self.scoped_name(¶m.name); let size = match param.param_type { NesType::U8 | NesType::I8 | NesType::Bool => 1, - NesType::U16 => 2, + NesType::U16 | NesType::I16 => 2, // Struct/array parameters are not supported // in v0.1; the parser already rejects them, // so defaulting to 1 byte here is just a @@ -1354,7 +1378,7 @@ impl Analyzer { // before the outer ones. let size = match &field.field_type { NesType::U8 | NesType::I8 | NesType::Bool => 1, - NesType::U16 => 2, + NesType::U16 | NesType::I16 => 2, NesType::Array(elem, count) => { // Reject arrays of structs for now — the // synthetic-variable model used by the @@ -1644,6 +1668,111 @@ impl Analyzer { }); } + /// Register a `save { var ... }` declaration. Save vars are + /// allocated from a separate bump pointer over the iNES SRAM + /// window (`$6000-$7FFF`), get a `VarAllocation` entry just + /// like a normal global, and live in the main symbol table so + /// user code reads/writes them with the usual `=` / load + /// syntax. The only thing special is their address range — + /// the codegen emits absolute-mode loads/stores automatically + /// because the address exceeds the zero-page boundary. + /// + /// Struct types in save blocks are accepted but not + /// flattened into per-field SRAM slots yet — the analyzer + /// emits an error if a save block declares a struct, since + /// the field-flattening path uses the main-RAM allocator and + /// would land struct fields back in `$0300+`. Scalars, + /// scalar arrays, and `u16`/`i16` are the supported set for + /// this first pass. + fn register_save_var(&mut self, var: &VarDecl) { + let key = self.scoped_name(&var.name); + if self.symbols.contains_key(&key) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate declaration of '{}'", var.name), + var.span, + )); + return; + } + // Save vars in SRAM are persisted across power cycles, so an + // initializer would either silently never run (current + // behaviour: globals init at reset, but save vars don't pass + // through that path) or actively clobber the player's saved + // data on every boot. Neither is what the user wants. Warn + // and tell them about the magic-byte sentinel pattern. + if var.init.is_some() { + self.diagnostics.push( + Diagnostic::warning( + ErrorCode::W0111, + format!( + "initializer on save-block field `{}` is ignored — \ + SRAM is preserved across power cycles", + var.name + ), + var.span, + ) + .with_help( + "first-power-on defaults need an explicit magic-byte sentinel: \ + check a known signature on boot, and only write defaults if it's missing", + ), + ); + } + if matches!(var.var_type, NesType::Struct(_)) { + self.diagnostics.push( + Diagnostic::error( + ErrorCode::E0201, + "struct types are not supported inside `save { }` blocks yet", + var.span, + ) + .with_help("use scalar fields (u8, i8, u16, i16, bool) or scalar arrays"), + ); + return; + } + let struct_sizes: HashMap = self + .struct_layouts + .iter() + .map(|(n, l)| (n.clone(), l.size)) + .collect(); + let size = type_size_with(&var.var_type, &struct_sizes); + let Some(end) = self.next_sram_addr.checked_add(size) else { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0301, + "save block exceeds the 8 KB SRAM window ($6000-$7FFF)", + var.span, + )); + return; + }; + if end > 0x8000 { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0301, + "save block exceeds the 8 KB SRAM window ($6000-$7FFF)", + var.span, + )); + return; + } + let address = self.next_sram_addr; + self.next_sram_addr = end; + // Mark the save-mode flag so the linker knows to flip + // the iNES battery bit. Stored on `var_allocations` via + // the address range itself — addresses ≥ `$6000` are SRAM + // by construction, so the analyzer needs no extra side + // table beyond the existing allocation list. + self.symbols.insert( + key.clone(), + Symbol { + name: key.clone(), + sym_type: var.var_type.clone(), + is_const: false, + span: var.span, + }, + ); + self.var_allocations.push(VarAllocation { + name: key, + address, + size, + }); + } + fn register_fun(&mut self, fun: &FunDecl) { if self.symbols.contains_key(&fun.name) { self.diagnostics.push(Diagnostic::error( @@ -2877,12 +3006,12 @@ fn compute_depth( fn type_size_with(t: &NesType, struct_sizes: &HashMap) -> u16 { match t { NesType::U8 | NesType::I8 | NesType::Bool => 1, - NesType::U16 => 2, + NesType::U16 | NesType::I16 => 2, NesType::Array(elem, count) => type_size_with(elem, struct_sizes) * count, NesType::Struct(name) => struct_sizes.get(name).copied().unwrap_or(0), } } fn is_integer_type(t: &NesType) -> bool { - matches!(t, NesType::U8 | NesType::I8 | NesType::U16) + matches!(t, NesType::U8 | NesType::I8 | NesType::U16 | NesType::I16) } diff --git a/src/assets/audio.rs b/src/assets/audio.rs index 4408019..4abb89a 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -768,6 +768,7 @@ mod tests { span: Span::dummy(), }, globals: Vec::new(), + saves: Vec::new(), constants: Vec::new(), enums: Vec::new(), structs: Vec::new(), diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 4af65bb..8e5cead 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -243,6 +243,7 @@ mod tests { span: Span::dummy(), }, globals: Vec::new(), + saves: Vec::new(), constants: Vec::new(), enums: Vec::new(), structs: Vec::new(), @@ -324,6 +325,7 @@ mod tests { span: Span::dummy(), }, globals: Vec::new(), + saves: Vec::new(), constants: Vec::new(), enums: Vec::new(), structs: Vec::new(), diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 705949a..41951a2 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -1831,7 +1831,16 @@ impl<'a> IrCodeGen<'a> { .map(|a| a.address) })) }; - match crate::asm::parse_inline(&to_parse) { + // Rewrite ca65-style local labels (`.foo:` / + // `BNE .foo`) into unique per-block identifiers so + // two inline-asm blocks in the same function can + // use overlapping local names without colliding in + // the linker's label table. `.foo` → `__ilab_N_foo` + // where N comes from the same monotonic suffix + // source our other local labels use. + let suffix = self.local_label_suffix(); + let mangled = mangle_dot_labels(&to_parse, &suffix); + match crate::asm::parse_inline(&mangled) { Ok(parsed) => self.instructions.extend(parsed), Err(msg) => { eprintln!("inline asm error: {msg}"); @@ -2951,6 +2960,69 @@ fn function_is_leaf(func: &IrFunction) -> bool { /// Addresses that fit in a byte become zero-page syntax (`$XX`), /// otherwise absolute (`$XXXX`). This lets users write /// `lda {score}` and have it resolve to `lda $10` or similar. +/// +/// Rewrite ca65-style local labels (`.label`) into globally unique +/// names scoped to a single inline-asm block. The parser doesn't +/// accept `.` as a label character, so we replace every `.IDENT` +/// occurrence — both `.foo:` definitions and `BNE .foo` references — +/// with `__ilab__IDENT`. `suffix` is the caller's +/// local-label counter so two inline-asm blocks in the same +/// function can both use `.loop:` without colliding in the +/// linker's label table. +/// +/// The dollar-prefixed hex literals (`$10`, `$FFFC`) and the +/// dot-prefixed assembler directives we don't yet accept (`.byte`, +/// `.word`, …) both survive: the substitution looks specifically +/// for `.` followed by an ident start character that the label +/// validator would accept (letter or underscore), so `$10` isn't +/// affected and directives don't match because their first letter +/// is immediately followed by more letters without a colon / +/// reference context. +fn mangle_dot_labels(body: &str, suffix: &str) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(body.len()); + let bytes = body.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + // Trip on a `.` that precedes an identifier-start character + // (letter or underscore). Anything else (whitespace, dollar + // signs, punctuation, digits) flows through unchanged. + if c == b'.' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) { + // Make sure the `.` isn't itself preceded by an + // identifier character (would be `name.field`, not a + // label). Leading position at the start of the buffer, + // start of a line, or after whitespace / punctuation is + // fine. + let is_label_ctx = i == 0 || !is_ident_continue(bytes[i - 1]); + if is_label_ctx { + // Consume the identifier after the dot. + let start = i + 1; + let mut end = start; + while end < bytes.len() && is_ident_continue(bytes[end]) { + end += 1; + } + // Append the mangled form without the leading dot. + let _ = write!(out, "__ilab_{suffix}_"); + out.push_str(&body[start..end]); + i = end; + continue; + } + } + out.push(c as char); + i += 1; + } + out +} + +fn is_ident_start(b: u8) -> bool { + b == b'_' || b.is_ascii_alphabetic() +} + +fn is_ident_continue(b: u8) -> bool { + b == b'_' || b.is_ascii_alphanumeric() +} + fn substitute_asm_vars Option>(body: &str, resolver: F) -> String { let mut out = String::with_capacity(body.len()); let bytes = body.as_bytes(); diff --git a/src/errors/diagnostic.rs b/src/errors/diagnostic.rs index fd7833d..e509e3e 100644 --- a/src/errors/diagnostic.rs +++ b/src/errors/diagnostic.rs @@ -50,6 +50,7 @@ pub enum ErrorCode { W0108, // array elements past byte 255 unreachable via 8-bit X index W0109, // too many literal-coord sprite draws on one scanline (NES 8/scanline limit) W0110, // `inline fun` declined — body shape not splicable, fell back to regular call + W0111, // initializer on a `save { var ... }` field (SRAM is preserved across power cycles, so init never runs) } impl fmt::Display for ErrorCode { @@ -82,6 +83,7 @@ impl fmt::Display for ErrorCode { Self::W0108 => "W0108", Self::W0109 => "W0109", Self::W0110 => "W0110", + Self::W0111 => "W0111", }; write!(f, "{code}") } @@ -99,7 +101,8 @@ impl ErrorCode { | Self::W0107 | Self::W0108 | Self::W0109 - | Self::W0110 => Level::Warning, + | Self::W0110 + | Self::W0111 => Level::Warning, _ => Level::Error, } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 8cbedf8..850ba7a 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -441,7 +441,7 @@ impl LoweringContext { _ => { let fval = self.eval_const(fexpr); let size = match field_type { - Some(NesType::U16) => 2, + Some(NesType::U16 | NesType::I16) => 2, _ => 1, }; self.globals.push(IrGlobal { @@ -913,7 +913,7 @@ impl LoweringContext { // of the initializer stored at base+1. Mirror the // `VarDecl` lowering in `lower_statement` so wide // inits round-trip cleanly. - if matches!(var.var_type, NesType::U16) { + if matches!(var.var_type, NesType::U16 | NesType::I16) { let (_, hi) = self.widen(val); self.emit(IrOp::StoreVarHi(var_id, hi)); } @@ -996,9 +996,15 @@ impl LoweringContext { } else { let val = self.lower_expr(init); self.emit(IrOp::StoreVar(var_id, val)); - // u16 var: write the high byte too, zero- - // extending narrow initializers. - if matches!(var.var_type, NesType::U16) { + // u16 / i16 var: write the high byte too, + // zero-extending narrow initializers. For + // `i16` negative literals, the IR lowering + // of integer literals already packs both + // bytes via the IntLiteral path, so `widen` + // produces the correct sign/zero-extension + // — narrow-to-wide stores here still do the + // right thing for both signednesses. + if matches!(var.var_type, NesType::U16 | NesType::I16) { let (_, hi) = self.widen(val); self.emit(IrOp::StoreVarHi(var_id, hi)); } @@ -1283,7 +1289,7 @@ impl LoweringContext { // u16 fields need the high byte written too — the // `widen` helper yields a zero-extended high temp // when the RHS is narrow. - if matches!(self.var_types.get(&full), Some(NesType::U16)) { + if matches!(self.var_types.get(&full), Some(NesType::U16 | NesType::I16)) { let (_, val_hi) = self.widen(val); self.emit(IrOp::StoreVarHi(field_var, val_hi)); } @@ -1296,8 +1302,10 @@ impl LoweringContext { let var_id = self.get_or_create_var(name); // Is the destination a u16 variable? Wide vars need // both bytes written on every assignment, otherwise - // the high byte silently stays stale. - let dest_is_u16 = matches!(self.var_types.get(name), Some(NesType::U16)); + // the high byte silently stays stale. Both `u16` + // and `i16` use this wide-store path. + let dest_is_u16 = + matches!(self.var_types.get(name), Some(NesType::U16 | NesType::I16)); match op { AssignOp::Assign => { let val = self.lower_expr(expr); @@ -1388,7 +1396,10 @@ impl LoweringContext { // follow the same two-byte path as u16 globals. let full_name = format!("{name}.{field}"); let var_id = self.get_or_create_var(&full_name); - let dest_is_u16 = matches!(self.var_types.get(&full_name), Some(NesType::U16)); + let dest_is_u16 = matches!( + self.var_types.get(&full_name), + Some(NesType::U16 | NesType::I16) + ); match op { AssignOp::Assign => { let val = self.lower_expr(expr); @@ -1684,10 +1695,10 @@ impl LoweringContext { let var_id = self.get_or_create_var(name); let t = self.fresh_temp(); self.emit(IrOp::LoadVar(t, var_id)); - // For u16 variables, also load the high byte and - // register the temp pair as wide so downstream ops - // can emit 16-bit IR when appropriate. - if matches!(self.var_types.get(name), Some(NesType::U16)) { + // For u16 / i16 variables, also load the high byte + // and register the temp pair as wide so downstream + // ops can emit 16-bit IR when appropriate. + if matches!(self.var_types.get(name), Some(NesType::U16 | NesType::I16)) { let hi = self.fresh_temp(); self.emit(IrOp::LoadVarHi(hi, var_id)); self.make_wide(t, hi); @@ -1712,7 +1723,10 @@ impl LoweringContext { let var_id = self.get_or_create_var(&full_name); let t = self.fresh_temp(); self.emit(IrOp::LoadVar(t, var_id)); - if matches!(self.var_types.get(&full_name), Some(NesType::U16)) { + if matches!( + self.var_types.get(&full_name), + Some(NesType::U16 | NesType::I16) + ) { let hi = self.fresh_temp(); self.emit(IrOp::LoadVarHi(hi, var_id)); self.make_wide(t, hi); @@ -1721,6 +1735,24 @@ impl LoweringContext { } Expr::BinaryOp(left, op, right, _) => self.lower_binop(left, *op, right), Expr::UnaryOp(op, inner, _) => { + // Constant-fold `-` into a wide two's- + // complement literal so a negative source like + // `-10` assigned to an `i16` stores $FFF6 (sign- + // extended) rather than the zero-extended $00F6 + // that a byte-level negate + widen would produce. + // Without this fold, `var vy: i16 = -10` would + // silently land at $00F6 = 246 in the target. + if let (UnaryOp::Negate, Expr::IntLiteral(v, _)) = (op, inner.as_ref()) { + let negated = (*v).wrapping_neg(); + let t = self.fresh_temp(); + self.emit(IrOp::LoadImm(t, negated as u8)); + if *v != 0 { + let hi = self.fresh_temp(); + self.emit(IrOp::LoadImm(hi, (negated >> 8) as u8)); + self.make_wide(t, hi); + } + return t; + } let val = self.lower_expr(inner); let t = self.fresh_temp(); match op { @@ -2235,7 +2267,7 @@ fn utf8_char_len(lead: u8) -> usize { fn type_size(t: &NesType) -> u16 { match t { NesType::U8 | NesType::I8 | NesType::Bool => 1, - NesType::U16 => 2, + NesType::U16 | NesType::I16 => 2, NesType::Array(elem, count) => type_size(elem) * count, // Struct sizes are resolved in the analyzer. IR lowering only // sees struct types on `var` declarations, which are skipped diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 83ad31a..dd266fd 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -467,6 +467,7 @@ impl<'a> Lexer<'a> { "palette" => TokenKind::KwPalette, "sfx" => TokenKind::KwSfx, "music" => TokenKind::KwMusic, + "save" => TokenKind::KwSave, "draw" => TokenKind::KwDraw, "play" => TokenKind::KwPlay, "stop_music" => TokenKind::KwStopMusic, @@ -486,6 +487,7 @@ impl<'a> Lexer<'a> { "u8" => TokenKind::KwU8, "i8" => TokenKind::KwI8, "u16" => TokenKind::KwU16, + "i16" => TokenKind::KwI16, "bool" => TokenKind::KwBool, "debug" => TokenKind::KwDebug, "as" => TokenKind::KwAs, diff --git a/src/lexer/token.rs b/src/lexer/token.rs index d9baf9a..0bd3a67 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -71,6 +71,7 @@ pub enum TokenKind { KwPalette, KwSfx, KwMusic, + KwSave, KwDraw, KwPlay, KwStopMusic, @@ -87,6 +88,7 @@ pub enum TokenKind { KwU8, KwI8, KwU16, + KwI16, KwBool, KwDebug, KwAs, @@ -182,6 +184,7 @@ impl std::fmt::Display for TokenKind { Self::KwPalette => write!(f, "palette"), Self::KwSfx => write!(f, "sfx"), Self::KwMusic => write!(f, "music"), + Self::KwSave => write!(f, "save"), Self::KwDraw => write!(f, "draw"), Self::KwPlay => write!(f, "play"), Self::KwStopMusic => write!(f, "stop_music"), @@ -198,6 +201,7 @@ impl std::fmt::Display for TokenKind { Self::KwU8 => write!(f, "u8"), Self::KwI8 => write!(f, "i8"), Self::KwU16 => write!(f, "u16"), + Self::KwI16 => write!(f, "i16"), Self::KwBool => write!(f, "bool"), Self::KwDebug => write!(f, "debug"), Self::KwAs => write!(f, "as"), diff --git a/src/linker/mod.rs b/src/linker/mod.rs index ee88866..cafd13d 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -45,6 +45,11 @@ pub struct Linker { mirroring: Mirroring, mapper: Mapper, header_format: HeaderFormat, + /// True when the program declared a `save { var ... }` block. + /// Threaded through to [`RomBuilder::set_battery`] so the iNES + /// header byte 6 bit 1 is set; emulators that respect it persist + /// the `$6000-$7FFF` SRAM region across power cycles. + has_battery: bool, } /// CHR data for a sprite, placed at a specific tile index in CHR ROM. @@ -191,6 +196,7 @@ impl Linker { mirroring, mapper: Mapper::NROM, header_format: HeaderFormat::Ines1, + has_battery: false, } } @@ -199,6 +205,7 @@ impl Linker { mirroring, mapper, header_format: HeaderFormat::Ines1, + has_battery: false, } } @@ -211,6 +218,14 @@ impl Linker { self } + /// Mark the ROM as battery-backed. Threaded through from + /// `analysis.has_battery_saves`; flips iNES byte-6 bit-1. + #[must_use] + pub fn with_battery(mut self, has_battery: bool) -> Self { + self.has_battery = has_battery; + self + } + /// Link all code sections into a .nes ROM. /// /// This is a thin wrapper around [`Linker::link_with_assets`] that passes @@ -787,6 +802,7 @@ impl Linker { if self.header_format == HeaderFormat::Nes2 { builder.enable_nes2(); } + builder.set_battery(self.has_battery); // Multi-bank layout: each switchable bank is an independent // 16 KB slot whose contents are either the assembled diff --git a/src/main.rs b/src/main.rs index 43c2c9e..221b5ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -601,6 +601,7 @@ mod tests { call_graph: HashMap::new(), max_depths: HashMap::new(), state_local_owners: HashMap::new(), + has_battery_saves: false, } } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index a704f9a..8d3383f 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -4,6 +4,16 @@ use crate::lexer::Span; pub struct Program { pub game: GameDecl, pub globals: Vec, + /// Battery-backed save variables, declared inside a top-level + /// `save { var ... }` block. The analyzer allocates these at + /// `$6000+` (the iNES SRAM region) instead of main RAM, and the + /// ROM builder flips byte-6 bit-1 of the iNES header so + /// emulators and cartridge boards persist this region across + /// power cycles. SRAM is uninitialized at first power-on, so + /// users should checksum or use a magic-byte sentinel to detect + /// a fresh battery — the compiler does not auto-initialize + /// save fields. + pub saves: Vec, pub constants: Vec, pub enums: Vec, pub structs: Vec, @@ -376,6 +386,14 @@ pub enum NesType { U8, I8, U16, + /// Signed 16-bit integer. Same two-byte layout as `U16` but the + /// analyzer tracks the signedness on literals, casts, and + /// assignments. Arithmetic emits the same carry-propagating + /// paired operations as `U16`; comparisons are currently + /// lowered through the same unsigned 16-bit compare path that + /// `U16` uses (matching the existing `I8` behaviour). A proper + /// signed-compare lowering would be a separate follow-up. + I16, Bool, Array(Box, u16), /// A user-declared struct, identified by its name. The analyzer @@ -389,6 +407,7 @@ impl std::fmt::Display for NesType { Self::U8 => write!(f, "u8"), Self::I8 => write!(f, "i8"), Self::U16 => write!(f, "u16"), + Self::I16 => write!(f, "i16"), Self::Bool => write!(f, "bool"), Self::Array(t, n) => write!(f, "{t}[{n}]"), Self::Struct(name) => write!(f, "{name}"), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 551e8ff..9824fb3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -132,6 +132,7 @@ impl Parser { let mut metasprites = Vec::new(); let mut sfx = Vec::new(); let mut music = Vec::new(); + let mut saves: Vec = Vec::new(); let mut banks = Vec::new(); let mut start_state = None; let mut on_frame = None; @@ -181,6 +182,34 @@ impl Parser { TokenKind::KwMusic => { music.push(self.parse_music_decl()?); } + TokenKind::KwSave => { + // `save { var x: u8 = 0 var y: u16 = 100 }` — + // a block of variable declarations that the + // analyzer allocates at $6000+ (cartridge SRAM + // window) instead of main RAM. Parsed as a + // sequence of plain `VarDecl`s; the analyzer + // and ROM builder are what make them battery- + // backed. + self.advance(); + self.expect(&TokenKind::LBrace)?; + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + match self.peek().clone() { + TokenKind::KwVar | TokenKind::KwFast | TokenKind::KwSlow => { + saves.push(self.parse_var_decl()?); + } + other => { + return Err(Diagnostic::error( + ErrorCode::E0201, + format!( + "expected `var` declaration inside `save {{ }}`, got `{other}`" + ), + self.current_span(), + )); + } + } + } + self.expect(&TokenKind::RBrace)?; + } TokenKind::KwBank => { let (bank, nested_funs) = self.parse_bank_decl()?; banks.push(bank); @@ -249,6 +278,7 @@ impl Parser { Ok(Program { game, globals, + saves, constants, enums, structs, @@ -2990,6 +3020,10 @@ impl Parser { self.advance(); NesType::U16 } + TokenKind::KwI16 => { + self.advance(); + NesType::I16 + } TokenKind::KwBool => { self.advance(); NesType::Bool diff --git a/src/pipeline.rs b/src/pipeline.rs index b1fc223..63271b3 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -227,7 +227,8 @@ pub fn compile_source( } let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper) - .with_header(program.game.header); + .with_header(program.game.header) + .with_battery(analysis.has_battery_saves); let switchable_banks: Vec = program .banks .iter() diff --git a/src/rom/mod.rs b/src/rom/mod.rs index 915a4f1..bf643c8 100644 --- a/src/rom/mod.rs +++ b/src/rom/mod.rs @@ -35,6 +35,12 @@ pub struct RomBuilder { mapper: u8, mirroring: Mirroring, header_format: HeaderFormat, + /// True when the program declared a `save { var ... }` block. + /// Sets byte 6 bit 1 of the iNES header so emulators and + /// cartridge boards persist `$6000-$7FFF` SRAM across power + /// cycles. Default false; the linker calls `set_battery(true)` + /// from `analysis.has_battery_saves`. + has_battery: bool, } impl RomBuilder { @@ -45,9 +51,18 @@ impl RomBuilder { mapper: 0, // NROM mirroring, header_format: HeaderFormat::Ines1, + has_battery: false, } } + /// Mark the ROM as carrying battery-backed SRAM. Flips iNES + /// header byte 6 bit 1; emulators that respect the flag (FCEUX, + /// Mesen, Nestopia, real flash carts) will load/save the + /// `$6000-$7FFF` window from/to a `.sav` file alongside the ROM. + pub fn set_battery(&mut self, has_battery: bool) { + self.has_battery = has_battery; + } + #[allow(dead_code)] pub fn set_mapper(&mut self, mapper: u8) { self.mapper = mapper; @@ -130,11 +145,16 @@ impl RomBuilder { rom.push(prg_banks as u8); // PRG ROM banks (16 KB units) — low 8 bits rom.push(chr_banks as u8); // CHR ROM banks (8 KB units) — low 8 bits - // Flags 6: mirroring, mapper low nibble + // Flags 6: mirroring (bit 0), battery-backed SRAM (bit 1), + // mapper low nibble (bits 4-7). Bits 2-3 (trainer, four-screen + // mirroring) stay zero — neither is a NEScript feature today. let mut flags6 = match self.mirroring { Mirroring::Horizontal => 0, Mirroring::Vertical => 1, }; + if self.has_battery { + flags6 |= 0x02; + } flags6 |= (self.mapper & 0x0F) << 4; rom.push(flags6); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 6cd729c..1cddf72 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1850,7 +1850,10 @@ pub fn gen_fade() -> Vec { // instead of "hold forever". out.push(Instruction::new(NOP, AM::Label("__fade_out".into()))); out.push(Instruction::new(ORA, AM::Immediate(0x00))); - out.push(Instruction::new(BNE, AM::LabelRelative("__fade_out_ok".into()))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_out_ok".into()), + )); out.push(Instruction::new(LDA, AM::Immediate(1))); out.push(Instruction::new(NOP, AM::Label("__fade_out_ok".into()))); out.push(Instruction::new(STA, AM::Absolute(FADE_STEP_FRAMES))); @@ -1895,7 +1898,10 @@ pub fn gen_fade() -> Vec { // body for a one-off five-step loop). out.push(Instruction::new(NOP, AM::Label("__fade_in".into()))); out.push(Instruction::new(ORA, AM::Immediate(0x00))); - out.push(Instruction::new(BNE, AM::LabelRelative("__fade_in_ok".into()))); + out.push(Instruction::new( + BNE, + AM::LabelRelative("__fade_in_ok".into()), + )); out.push(Instruction::new(LDA, AM::Immediate(1))); out.push(Instruction::new(NOP, AM::Label("__fade_in_ok".into()))); out.push(Instruction::new(STA, AM::Absolute(FADE_STEP_FRAMES))); diff --git a/tests/emulator/goldens/i16_demo.audio.hash b/tests/emulator/goldens/i16_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/i16_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/i16_demo.png b/tests/emulator/goldens/i16_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..9e23f8fb5a6effec85a3e7491cdb0de1593610e5 GIT binary patch literal 844 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WQ;;45?szdvIg!Z3YIG z1K*Wdqt7l7-SVF$%F%d3>hlAwjU|EFf9}?*{^T=Q$(Uw(U=7=hs|=f^62h3zWHm(c z8mt}_983Z7+P|T{JU0Aasf_!*iwyE_cdeVVKKyzBpC5r2Nq zYDT+jg)$(Wh5P1N#-IPQRIGboFyt=akR{0BA>7F#rGn literal 0 HcmV?d00001 diff --git a/tests/emulator/goldens/sram_demo.audio.hash b/tests/emulator/goldens/sram_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/sram_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/sram_demo.png b/tests/emulator/goldens/sram_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..3a2f76859b18bd594ea2fd23e357aceb1c62ab8d GIT binary patch literal 844 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ7WQ;;45?szdvIg!Z3YIG z1K*Wdqt7l7-SVF$%F%d3>hlAwjU|EFf9}?*{^T=Q$(Uw(U=7=hs|=f^62h3zWHm(c z8mu01g8x{4R9r888h^a-i7fAji|byW`dRg?#!=JJAoMc1t}- zmOVZZs8b%O4JgnLWInd|WFPOjkGW Vec { let mut instructions = codegen.generate(&ir_program); nescript::codegen::peephole::optimize(&mut instructions); - let linker = Linker::new(program.game.mirroring); + let linker = Linker::new(program.game.mirroring).with_battery(analysis.has_battery_saves); linker.link_with_all_assets(&instructions, &sprites, &sfx, &music) } @@ -3412,6 +3412,283 @@ start Main ); } +#[test] +fn inline_asm_dot_labels_are_per_block_unique() { + // Two inline-asm blocks in the same function can both use + // `.loop:` without colliding in the linker's label table. + // The codegen mangles `.X` to `__ilab__X` where `` is + // the per-call-site monotonic suffix. + let source = r#" +game "DotLabelCollide" { mapper: NROM } +var counter: u8 = 0 +fun double_loop() { + asm { + LDX #5 + .loop: + DEX + BNE .loop + STX {counter} + } + asm { + LDX #3 + .loop: + DEX + BNE .loop + STX {counter} + } +} +on frame { double_loop() } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + // Both `.loop:` definitions should be present, mangled to + // distinct `__ilab__loop` labels. + let mangled: Vec<&String> = linked + .labels + .keys() + .filter(|k| k.starts_with("__ilab_") && k.ends_with("_loop")) + .collect(); + assert!( + mangled.len() >= 2, + "expected at least two mangled `.loop` labels, found {}: {:?}", + mangled.len(), + mangled + ); +} + +#[test] +fn inline_asm_dot_label_does_not_match_dollar_hex() { + // `STA $2002` writes to PPU status; the substituter must NOT + // mangle the `$2002` operand — only `.IDENT` patterns. This is + // a regression check for the dot-vs-dollar disambiguation. + let source = r#" +game "DollarOk" { mapper: NROM } +fun touch_ppu() { + asm { + LDA $2002 + } +} +on frame { touch_ppu() } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis.diagnostics.iter().all(|d| !d.is_error()), + "$2002 in inline asm should compile cleanly; got {:?}", + analysis.diagnostics + ); + // Compile to a ROM — if the mangler corrupted the operand, + // the asm parser would error and the linker would emit BRK. + let _rom = compile(source); +} + +#[test] +fn save_block_allocates_at_sram_window_and_sets_battery_bit() { + // `save { var x: u16 = 0 }` should land at $6000+ (iNES SRAM + // window) and flip iNES header byte-6 bit-1 so emulators + // persist the region across power cycles. + let source = r#" +game "SaveProg" { mapper: NROM } +save { + var hi: u16 = 0 + var coins: u8 = 0 +} +on frame { + coins += 1 +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis.has_battery_saves, + "save block should set has_battery_saves" + ); + let hi_alloc = analysis + .var_allocations + .iter() + .find(|a| a.name == "hi") + .expect("hi should have a VarAllocation"); + assert_eq!(hi_alloc.address, 0x6000, "hi should land at $6000"); + assert_eq!(hi_alloc.size, 2, "u16 hi should be 2 bytes"); + let coins_alloc = analysis + .var_allocations + .iter() + .find(|a| a.name == "coins") + .expect("coins should have a VarAllocation"); + assert_eq!( + coins_alloc.address, 0x6002, + "coins should land right after hi" + ); + // Build the ROM and verify the iNES header bit. + let rom = compile(source); + assert_eq!( + rom[6] & 0x02, + 0x02, + "iNES byte 6 bit 1 should be set for battery" + ); +} + +#[test] +fn no_save_block_leaves_battery_bit_clear() { + // Sanity: a program with no save block must NOT set the + // battery bit. + let source = r#" +game "NoSave" { mapper: NROM } +on frame { } +start Main +"#; + let rom = compile(source); + assert_eq!(rom[6] & 0x02, 0, "no save block → no battery bit"); +} + +#[test] +fn save_block_initializer_warns() { + // Initializers on save vars are silently dropped at codegen + // time (globals init at reset, save vars don't). Warn so the + // user knows their default isn't taking effect. + let source = r#" +game "SaveInit" { mapper: NROM } +save { + var hi: u16 = 12345 +} +on frame { } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis + .diagnostics + .iter() + .any(|d| !d.is_error() + && matches!(d.code, nescript::errors::ErrorCode::W0111)), + "save-block initializer should emit W0111; got {:?}", + analysis.diagnostics + ); +} + +#[test] +fn save_block_struct_field_errors() { + // Struct types in save blocks aren't supported yet — must error, + // not silently allocate the struct fields back in main RAM. + let source = r#" +game "BadSave" { mapper: NROM } +struct Stats { hp: u8, mp: u8 } +save { + var stats: Stats +} +on frame { } +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis + .diagnostics + .iter() + .any(|d| d.is_error() && d.message.contains("struct types are not supported")), + "struct in save block should error; got {:?}", + analysis.diagnostics + ); +} + +#[test] +fn i16_negative_literal_sign_extends_to_wide_store() { + // `var vy: i16 = -10` should store $F6 in the low byte and + // $FF in the high byte — sign-extended two's complement. + // A pre-fix lowering would byte-negate to $F6 and then + // zero-extend, producing $00F6 (= 246) which is wrong for + // signed semantics. Regression assert: scan the assembled + // ROM for `LDA #$F6 / STA / LDA #$FF / STA `. + let source = r#" +game "I16Neg" { mapper: NROM } +var vy: i16 = -10 +on frame { + vy = vy + 1 +} +start Main +"#; + let (program, _) = nescript::parser::parse(source); + let program = program.unwrap(); + let analysis = analyzer::analyze(&program); + let mut ir_program = ir::lower(&program, &analysis); + optimizer::optimize(&mut ir_program); + let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap(); + let sfx = assets::resolve_sfx(&program).unwrap(); + let music = assets::resolve_music(&program).unwrap(); + let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program) + .with_sprites(&sprites) + .with_audio(&sfx, &music); + let mut instructions = codegen.generate(&ir_program); + nescript::codegen::peephole::optimize(&mut instructions); + let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed( + &instructions, + &sprites, + &sfx, + &music, + &[], + &[], + &[], + ); + // `LDA #$F6` = `A9 F6`; `LDA #$FF` = `A9 FF`. Find them + // adjacent to the var's address, accounting for the STA + // instructions in between. + // + // We scan for the byte-pair pattern `A9 F6` followed within + // ~10 bytes by `A9 FF`. That's tight enough to catch the + // neg-literal store sequence and loose enough to survive + // any peephole reordering. + let rom = &linked.rom; + let lda_f6 = rom + .windows(2) + .position(|w| w == [0xA9_u8, 0xF6]) + .expect("should emit LDA #$F6 for the i16 negative-literal low byte"); + let near = &rom[lda_f6..(lda_f6 + 16).min(rom.len())]; + assert!( + near.windows(2).any(|w| w == [0xA9_u8, 0xFF]), + "should emit LDA #$FF (sign-extended high byte) within 16 bytes of LDA #$F6" + ); +} + +#[test] +fn i16_compiles_with_arithmetic_and_compare() { + // End-to-end compile of an i16 program with `+`, `-`, `>=`, + // and `as u8` cast. Just makes sure the type lowering path + // doesn't panic; the ROM-shape correctness is covered by + // `i16_demo.nes` and its emulator golden. + let source = include_str!("../examples/i16_demo.ne"); + let rom = compile(source); + let info = rom::validate_ines(&rom).expect("should be valid iNES"); + assert_eq!(info.mapper, 0); +} + #[test] fn fade_out_emits_jsr_and_forces_palette_bright() { // `fade_out(n)` should emit a JSR to `__fade_out` and also