diff --git a/README.md b/README.md index 25e433b..ec95ba7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,10 @@ start Main - **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 -- **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state) +- **Multiple mappers** -- NROM, MMC1, UxROM, MMC3 (including multi-scanline IRQ dispatch per state), AxROM (mapper 7), CNROM (mapper 3) +- **Runtime PRNG** -- `rand8()`, `rand16()`, `seed_rand(s)` backed by a zero-cost-when-unused xorshift LFSR +- **Edge-triggered input** -- `p1.button.a.pressed` / `.released` for menu / one-shot input handling +- **Palette brightness fades** -- `set_palette_brightness(level)` for cheap neslib-style screen fades - **Audio subsystem** -- frame-walking pulse driver with user-declared `sfx`/`music` blocks, builtin effects and tracks, period table, and zero-cost elision when unused - **Palette & background pipeline** -- `palette` and `background` blocks, initial values loaded at reset, vblank-safe `set_palette` / `load_background` runtime swaps - **Asset pipeline** -- PNG-to-CHR conversion, inline tile data, sfx envelopes, music note streams diff --git a/examples/README.md b/examples/README.md index 3e28b76..68b8251 100644 --- a/examples/README.md +++ b/examples/README.md @@ -41,6 +41,11 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. | | `feature_canary.ne` | **regression canary**, state-locals, uninitialized struct-field writes, u16, arrays, `slow` placement, function returns | A minimal program whose sole job is to paint a green universal backdrop at frame 180 when every memory-affecting language construct round-trips a write through the compiler correctly, and to flip to red if any check fails. Each check writes a distinctive byte through one construct (state-local, uninit struct field, u8/u16 global, array element, `slow`-placed u8, function call return), reads it back, and clears `all_ok` on mismatch. Because the emulator harness compares pixels at frame 180, any compiler regression that silently drops one of these writes turns the committed golden red — the structural counter to the "goldens capture whatever happens, not what should happen" failure mode that let PR #31 survive for a year. | | `sha256.ne` | **interactive SHA-256**, inline-asm 32-bit primitives, multi-file | A full FIPS 180-4 SHA-256 hasher split across `examples/sha256/*.ne`. An on-screen 5×8 keyboard grid lets the player type up to 16 ASCII characters (`A`..`Z`, `0`..`9`, space, `.`, backspace, enter), and pressing ↵ runs the 48-entry message-schedule expansion + 64-round compression on the NES itself. Every 32-bit primitive (`copy`, `xor`, `and`, `add`, `not`, rotate-right, shift-right) is hand-tuned inline assembly that walks the four little-endian bytes of a word with `LDA {wk},X` / `ADC {wk},Y` chains, so a whole round costs a few thousand cycles. The phased driver runs four schedule steps or four rounds per frame so the full compression finishes well under a second, and the 64-character hex digest renders as sprites in 8 rows of 8 glyphs at the bottom of the screen. The jsnes golden auto-types `"NES"` after 1 s of keyboard idle and captures its hash `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. | +| `prng_demo.ne` | `rand8()`, `rand16()`, `seed_rand()` | Exercises the runtime xorshift PRNG end-to-end. Four sprite positions are drawn from fresh `rand8()` draws every frame with a `rand16()` sample mixed in. `seed_rand(0x1234)` pins the initial state so the golden is deterministic. The `__rand_used` marker gates linking of `gen_prng` + the reset-time seed — programs that never call any of the three get zero ROM / cycle overhead. | +| `edge_input_demo.ne` | `p1.button.a.pressed`, `p1.button.b.released` | Demonstrates edge-triggered input. The A-sprite advances exactly once per press transition (holding the button does nothing) and the B-sprite advances on release. Lowering emits `IrOp::ReadInputEdge`, which stores the previous-frame input byte into main RAM and XORs it against the current byte at the read site. The NMI handler snapshots both prev bytes before strobing, gated on the `__edge_input_used` marker. | +| `palette_brightness_demo.ne` | `set_palette_brightness(level)` | Cycles through the 9 brightness levels (0 = blank, 4 = normal, 8 = max emphasis) every 20 frames. Exercises the neslib-style `pal_bright` mapping onto `$2001` PPU mask emphasis bits. The runtime routine `__set_palette_brightness` is spliced in only when user code references the builtin. | +| `axrom_simple.ne` | `mapper: AxROM` (mapper 7) | Single-screen AxROM demo. The linker pads PRG to 32 KB (one blank 16 KB bank plus our 16 KB fixed bank) so emulators that enforce mapper-7's 32 KB page size boot cleanly. Register layout: bit 4 of `$8000` selects single-screen lower / upper nametable. | +| `cnrom_simple.ne` | `mapper: CNROM` (mapper 3) | CNROM demo. Fixed 32 KB PRG, switchable 8 KB CHR. Single-bank CNROM is functionally equivalent to NROM at the PRG level, but the iNES header reports mapper 3 and the runtime writes a CHR bank 0 select at reset. | ## Emulator Controls diff --git a/examples/axrom_simple.ne b/examples/axrom_simple.ne new file mode 100644 index 0000000..b2f1354 --- /dev/null +++ b/examples/axrom_simple.ne @@ -0,0 +1,25 @@ +// AxROM (mapper 7) demo — single 32 KB PRG with single-screen +// mirroring. Most homebrew AxROM games have multiple 32 KB pages; +// this minimal demo runs in bank 0 of a 32 KB-padded ROM to +// exercise the mapper's reset, bank-select register, and iNES +// header emission. + +game "AxROM Demo" { + mapper: AxROM +} + +var px: u8 = 120 +var dx: u8 = 1 + +on frame { + if dx == 1 { + px += 1 + if px >= 240 { dx = 0 } + } else { + px -= 1 + if px == 0 { dx = 1 } + } + draw Ball at: (px, 100) +} + +start Main diff --git a/examples/axrom_simple.nes b/examples/axrom_simple.nes new file mode 100644 index 0000000..2e2f52d Binary files /dev/null and b/examples/axrom_simple.nes differ diff --git a/examples/cnrom_simple.ne b/examples/cnrom_simple.ne new file mode 100644 index 0000000..8c31bc3 --- /dev/null +++ b/examples/cnrom_simple.ne @@ -0,0 +1,24 @@ +// CNROM (mapper 3) demo — fixed 32 KB PRG, switchable 8 KB CHR. +// Single-bank CNROM exercises the mapper reset, header emission, +// and compatible runtime — functionally equivalent to NROM but +// with a different iNES mapper number. + +game "CNROM Demo" { + mapper: CNROM +} + +var px: u8 = 120 +var dx: u8 = 1 + +on frame { + if dx == 1 { + px += 1 + if px >= 240 { dx = 0 } + } else { + px -= 1 + if px == 0 { dx = 1 } + } + draw Ball at: (px, 100) +} + +start Main diff --git a/examples/cnrom_simple.nes b/examples/cnrom_simple.nes new file mode 100644 index 0000000..55e2f21 Binary files /dev/null and b/examples/cnrom_simple.nes differ diff --git a/examples/edge_input_demo.ne b/examples/edge_input_demo.ne new file mode 100644 index 0000000..c71ec9c --- /dev/null +++ b/examples/edge_input_demo.ne @@ -0,0 +1,42 @@ +// Edge-triggered input demo — the A / B buttons toggle a pair +// of sprites using `.pressed` / `.released` rather than the +// level-state `p1.button.a`. Held buttons only fire once per +// press, which is the canonical menu / one-shot action pattern. + +game "Edge Input Demo" { + mapper: NROM +} + +var ax: u8 = 64 +var bx: u8 = 120 +var toggle_a: u8 = 0 +var toggle_b: u8 = 0 + +on frame { + // Each press moves the A-sprite 8 pixels right. Because + // `.pressed` fires once per press transition, holding the + // button down does not accelerate movement. + if p1.button.a.pressed { + ax += 8 + toggle_a = toggle_a ^ 1 + } + + // Release moves the B-sprite 4 pixels right on letting go. + if p1.button.b.released { + bx += 4 + toggle_b = toggle_b ^ 1 + } + + // Drive the frame colours off the toggles so the output is + // observable in the emulator golden. + draw Ball at: (ax, 80) + draw Ball at: (bx, 120) + + // A tiny visual beacon for "toggle_a" / "toggle_b" so the + // golden captures the state even before the user presses + // anything. + if toggle_a == 1 { draw Ball at: (ax, 90) } + if toggle_b == 1 { draw Ball at: (bx, 130) } +} + +start Main diff --git a/examples/edge_input_demo.nes b/examples/edge_input_demo.nes new file mode 100644 index 0000000..cbee3f1 Binary files /dev/null and b/examples/edge_input_demo.nes differ diff --git a/examples/palette_brightness_demo.ne b/examples/palette_brightness_demo.ne new file mode 100644 index 0000000..beb184e --- /dev/null +++ b/examples/palette_brightness_demo.ne @@ -0,0 +1,28 @@ +// Palette brightness demo — cycle through the 9 brightness levels +// every ~20 frames. Exercises the `set_palette_brightness` builtin, +// which writes PPU mask emphasis bits for cheap neslib-style fades. + +game "Palette Brightness Demo" { + mapper: NROM +} + +var frame: u8 = 0 +var level: u8 = 4 // normal + +on frame { + frame += 1 + // Every 20 frames, bump the brightness level. Roll back to 0 + // at 9 so we cycle through the full 0..8 range. + if frame >= 20 { + frame = 0 + level += 1 + if level >= 9 { + level = 0 + } + set_palette_brightness(level) + } + + draw Ball at: (120, 100) +} + +start Main diff --git a/examples/palette_brightness_demo.nes b/examples/palette_brightness_demo.nes new file mode 100644 index 0000000..52debb1 Binary files /dev/null and b/examples/palette_brightness_demo.nes differ diff --git a/examples/prng_demo.ne b/examples/prng_demo.ne new file mode 100644 index 0000000..423e3bd --- /dev/null +++ b/examples/prng_demo.ne @@ -0,0 +1,45 @@ +// PRNG demo — sprite positions driven by the runtime PRNG. +// +// Each frame draws four sprites at addresses pulled from the +// xorshift-style `rand8()` intrinsic, with an extra `rand16()` +// sample binned into a horizontal velocity. Exercises `rand8`, +// `rand16`, and `seed_rand` end-to-end. + +game "PRNG Demo" { + mapper: NROM +} + +var seeded: u8 = 0 + +on frame { + if seeded == 0 { + // Pin the seed so the recording is deterministic. The + // runtime forces bit 0 of the low byte high so a zero + // seed doesn't stick the LFSR. + seed_rand(0x1234) + seeded = 1 + } + + // Four random sprites per frame. Each call pulls fresh + // entropy from the shared PRNG state, so the positions + // drift over time instead of staying fixed. + var x1: u8 = rand8() + var y1: u8 = rand8() + draw Ball at: (x1, y1) + + var x2: u8 = rand8() + var y2: u8 = rand8() + draw Ball at: (x2, y2) + + // rand16() returns u16; truncate to u8 for the draw. + var r: u16 = rand16() + var x3: u8 = r as u8 + var y3: u8 = (r >> 8) as u8 + draw Ball at: (x3, y3) + + var x4: u8 = rand8() + var y4: u8 = rand8() + draw Ball at: (x4, y4) +} + +start Main diff --git a/examples/prng_demo.nes b/examples/prng_demo.nes new file mode 100644 index 0000000..d59a14a Binary files /dev/null and b/examples/prng_demo.nes differ diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 17ccb05..0ea7231 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -973,7 +973,12 @@ impl Analyzer { // If the function is known, validate its call signature. // Undefined-function errors are surfaced elsewhere (for // Statement::Call) and via the call-graph pass. - if self.function_signatures.contains_key(name) { + if is_intrinsic(name) { + // Intrinsics can appear in expression position too + // (e.g. `var x = rand8()`). Validate arity here so + // typos and bad-arity calls don't slip past. + self.check_intrinsic_args(name, args, *span); + } else if self.function_signatures.contains_key(name) { self.check_call_signature(name, args, *span); } for arg in args { @@ -1049,6 +1054,20 @@ impl Analyzer { } } Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::ButtonRead(_, _, _) => {} + Expr::ButtonEdge(_, button, _, span) => { + // Same validation as ButtonRead: the button name + // must be recognised. `crate::ir::lowering::button_mask` + // returns 0 for unknown names which would silently + // compile to "always false"; reject here so typos + // surface at compile time. + if !is_known_button(button) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!("unknown button '{button}'"), + *span, + )); + } + } } } @@ -2236,6 +2255,7 @@ impl Analyzer { if *expected == NesType::Bool { match expr { Expr::ButtonRead(..) + | Expr::ButtonEdge(..) | Expr::BinaryOp( _, BinOp::Eq @@ -2280,7 +2300,7 @@ impl Analyzer { } Expr::BoolLiteral(_, _) => Some(NesType::Bool), Expr::Ident(name, _) => self.resolve_symbol(name).map(|s| s.sym_type.clone()), - Expr::ButtonRead(_, _, _) => Some(NesType::Bool), + Expr::ButtonRead(_, _, _) | Expr::ButtonEdge(_, _, _, _) => Some(NesType::Bool), Expr::BinaryOp(_, op, _, _) => match op { BinOp::Eq | BinOp::NotEq @@ -2294,7 +2314,12 @@ impl Analyzer { }, Expr::UnaryOp(UnaryOp::Not, _, _) => Some(NesType::Bool), Expr::UnaryOp(_, _, _) => Some(NesType::U8), - Expr::Call(_, _, _) => Some(NesType::U8), // Simplified for M1 + Expr::Call(name, _, _) => match name.as_str() { + // PRNG intrinsics: rand8 returns u8, rand16 returns u16. + "rand8" => Some(NesType::U8), + "rand16" => Some(NesType::U16), + _ => Some(NesType::U8), // Simplified for M1 + }, Expr::ArrayIndex(name, _, _) => { self.resolve_symbol(name).and_then(|s| match &s.sym_type { NesType::Array(elem, _) => Some(elem.as_ref().clone()), @@ -2551,7 +2576,19 @@ fn is_small_constant(expr: &Expr) -> bool { /// compiler. Intrinsics don't need a declaration and may have /// special codegen (e.g. \`poke\` / \`peek\` write to raw addresses). fn is_intrinsic(name: &str) -> bool { - matches!(name, "poke" | "peek") + matches!( + name, + "poke" | "peek" | "rand8" | "rand16" | "seed_rand" | "set_palette_brightness" + ) +} + +/// True if `name` is one of the NES controller's eight buttons. +/// Mirrors the `button_mask` table in `ir::lowering`. +fn is_known_button(name: &str) -> bool { + matches!( + name, + "a" | "b" | "select" | "start" | "up" | "down" | "left" | "right" + ) } impl Analyzer { @@ -2576,6 +2613,33 @@ impl Analyzer { span, )); } + "rand8" | "rand16" if !args.is_empty() => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!("`{name}` takes no arguments, got {}", args.len()), + span, + )); + } + "seed_rand" if args.len() != 1 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`seed_rand` takes exactly 1 argument (seed: u16), got {}", + args.len() + ), + span, + )); + } + "set_palette_brightness" if args.len() != 1 => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`set_palette_brightness` takes exactly 1 argument (level: u8 0..8), got {}", + args.len() + ), + span, + )); + } _ => {} } } @@ -2673,7 +2737,8 @@ fn collect_calls_expr(expr: &Expr, calls: &mut Vec) { | Expr::BoolLiteral(_, _) | Expr::Ident(_, _) | Expr::FieldAccess(_, _, _) - | Expr::ButtonRead(_, _, _) => {} + | Expr::ButtonRead(_, _, _) + | Expr::ButtonEdge(_, _, _, _) => {} } } diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 1b4f481..31a188d 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -208,6 +208,22 @@ pub struct IrCodeGen<'a> { /// the NMI's shift loop, saving ~6 bytes of code and ~30 cycles /// per frame. p2_input_used: bool, + /// Set to true the first time we lower `rand8()`/`rand16()`/ + /// `seed_rand()`. Drives the `__rand_used` marker label — + /// the linker uses it to splice `runtime::gen_prng` into PRG + /// ROM and seed the state at reset. + rand_used: bool, + /// Set to true the first time we lower `set_palette_brightness`. + /// Drives the `__palette_bright_used` marker label — the + /// linker uses it to splice `runtime::gen_palette_brightness` + /// into PRG ROM. + palette_bright_used: bool, + /// Set to true the first time we lower `IrOp::ReadInputEdge` + /// (`p1.a.pressed` / `p1.a.released`). Drives the + /// `__edge_input_used` marker label — the linker uses it to + /// splice the prev-input snapshot after the NMI's shift loop + /// so edge detection sees stable current/previous bytes. + edge_input_used: bool, /// Source-location markers produced from [`IrOp::SourceLoc`]. /// Each entry is a `(label_name, span)` pair — the codegen /// emits a unique label-definition pseudo-op at the current @@ -426,6 +442,9 @@ impl<'a> IrCodeGen<'a> { default_sprite_used: false, p1_input_used: false, p2_input_used: false, + rand_used: false, + palette_bright_used: false, + edge_input_used: false, source_locs: Vec::new(), next_source_loc: 0, emit_source_locs: false, @@ -1908,6 +1927,80 @@ impl<'a> IrCodeGen<'a> { b_lo, b_hi, } => self.gen_cmp16(*dest, *a_lo, *a_hi, *b_lo, *b_hi, Cmp16Kind::GtEq), + IrOp::Rand8(dest) => { + self.emit_rand_marker(); + self.emit(JSR, AM::Label("__rand8".into())); + // A now holds the new low byte. + self.store_temp(*dest); + } + IrOp::Rand16(dest_lo, dest_hi) => { + self.emit_rand_marker(); + self.emit(JSR, AM::Label("__rand16".into())); + // A = lo, X = hi. + self.store_temp(*dest_lo); + self.emit(TXA, AM::Implied); + self.store_temp(*dest_hi); + } + IrOp::SeedRand(lo, hi) => { + self.emit_rand_marker(); + // Load hi into X first (since the seed routine + // expects A=lo, X=hi and loading A last leaves it + // in the right register). + let hi_addr = self.temp_addr(*hi); + self.emit(LDX, AM::ZeroPage(hi_addr)); + self.load_temp(*lo); + self.emit(JSR, AM::Label("__rand_seed".into())); + } + IrOp::SetPaletteBrightness(level) => { + self.emit_palette_bright_marker(); + self.load_temp(*level); + self.emit(JSR, AM::Label("__set_palette_brightness".into())); + } + IrOp::ReadInputEdge { + dest, + player, + mask, + released, + } => { + // Mark input usage so the NMI strobe loop gets spliced. + if *player == 0 { + self.p1_input_used = true; + } else { + self.p2_input_used = true; + } + // Mark edge-input usage so the NMI emits the prev-state + // snapshot after the shift loop. + self.edge_input_used = true; + // Current input byte: + let curr_addr = if *player == 0 { + crate::runtime::ZP_INPUT_P1 + } else { + crate::runtime::ZP_INPUT_P2 + }; + // Previous-frame input byte. Parked in main RAM below + // the debug/audio/prng block so it doesn't collide + // with ZP; the analyzer never allocates there. + let prev_addr: u16 = if *player == 0 { + crate::runtime::PREV_INPUT_P1 + } else { + crate::runtime::PREV_INPUT_P2 + }; + // For `pressed`: curr & ~prev = curr AND (prev XOR $FF) — simpler: + // LDA prev ; EOR #$FF ; AND curr ; AND #mask + // For `released`: ~curr & prev = prev AND (curr XOR $FF): + // LDA curr ; EOR #$FF ; AND prev ; AND #mask + if *released { + self.emit(LDA, AM::ZeroPage(curr_addr)); + self.emit(EOR, AM::Immediate(0xFF)); + self.emit(AND, AM::Absolute(prev_addr)); + } else { + self.emit(LDA, AM::Absolute(prev_addr)); + self.emit(EOR, AM::Immediate(0xFF)); + self.emit(AND, AM::ZeroPage(curr_addr)); + } + self.emit(AND, AM::Immediate(*mask)); + self.store_temp(*dest); + } IrOp::SourceLoc(span) => { // Emit a uniquely-named label-definition pseudo-op // at the current codegen position — but only when @@ -2269,6 +2362,30 @@ impl<'a> IrCodeGen<'a> { if self.p2_input_used { self.emit_label("__p2_input_used"); } + if self.rand_used { + self.emit_label("__rand_used"); + } + if self.palette_bright_used { + self.emit_label("__palette_bright_used"); + } + if self.edge_input_used { + self.emit_label("__edge_input_used"); + } + } + + /// Emit the `__rand_used` marker label at most once per + /// program. The linker scans for this label to decide whether + /// to splice `runtime::gen_prng` into PRG ROM and seed the + /// state at reset. + fn emit_rand_marker(&mut self) { + self.rand_used = true; + } + + /// Emit the `__palette_bright_used` marker at most once per + /// program. The linker uses it to splice + /// `runtime::gen_palette_brightness` into PRG ROM. + fn emit_palette_bright_marker(&mut self) { + self.palette_bright_used = true; } /// Emit the MMC3 `__irq_user` handler that dispatches on the @@ -2659,7 +2776,11 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::Mul(..) | IrOp::Div(..) | IrOp::Mod(..) - | IrOp::Transition(..) => true, + | IrOp::Transition(..) + | IrOp::Rand8(..) + | IrOp::Rand16(..) + | IrOp::SeedRand(..) + | IrOp::SetPaletteBrightness(..) => true, IrOp::InlineAsm(body) => { // Strip the raw-asm magic prefix if present so // we don't false-match the marker characters. @@ -2721,6 +2842,7 @@ fn function_is_leaf(func: &IrFunction) -> bool { | IrOp::PlaySfx(..) | IrOp::StartMusic(..) | IrOp::StopMusic + | IrOp::ReadInputEdge { .. } | IrOp::SourceLoc(..) => false, }; if is_jsr_emitting { @@ -2913,6 +3035,7 @@ fn op_source_temps(op: &IrOp) -> Vec { .. } => vec![*a_lo, *a_hi, *b_lo, *b_hi], IrOp::ReadInput(_, _) + | IrOp::ReadInputEdge { .. } | IrOp::WaitFrame | IrOp::CycleSprites | IrOp::Transition(_) @@ -2921,9 +3044,13 @@ fn op_source_temps(op: &IrOp) -> Vec { | IrOp::PlaySfx(_) | IrOp::StartMusic(_) | IrOp::StopMusic + | IrOp::Rand8(_) + | IrOp::Rand16(_, _) | IrOp::SetPalette(_) | IrOp::LoadBackground(_) | IrOp::SourceLoc(_) => Vec::new(), + IrOp::SeedRand(lo, hi) => vec![*lo, *hi], + IrOp::SetPaletteBrightness(level) => vec![*level], } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 9ef8bd4..bc03017 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -1119,6 +1119,20 @@ impl LoweringContext { self.emit(IrOp::Poke(addr, val)); } } + // `seed_rand(x)` — install `x` as the new PRNG + // state. `x` is widened to u16 so a narrow seed + // still lands in both bytes of the state. + "seed_rand" if args.len() == 1 => { + let seed = self.lower_expr(&args[0]); + let (lo, hi) = self.widen(seed); + self.emit(IrOp::SeedRand(lo, hi)); + } + // `set_palette_brightness(level)` — translate a + // 0..8 level into $2001 mask bits. + "set_palette_brightness" if args.len() == 1 => { + let level = self.lower_expr(&args[0]); + self.emit(IrOp::SetPaletteBrightness(level)); + } _ => { // Inline expansion at statement context // splices either the return expression @@ -1668,6 +1682,21 @@ impl LoweringContext { return t; } } + // `rand8()` — draw the next 8 bits from the PRNG. + if name == "rand8" && args.is_empty() { + let t = self.fresh_temp(); + self.emit(IrOp::Rand8(t)); + return t; + } + // `rand16()` — draw the next 16 bits. Return a + // wide-marked low temp so callers get u16 semantics. + if name == "rand16" && args.is_empty() { + let lo = self.fresh_temp(); + let hi = self.fresh_temp(); + self.emit(IrOp::Rand16(lo, hi)); + self.make_wide(lo, hi); + return lo; + } // `inline fun` bodies captured by // `capture_inline_bodies` expand in-place here: // no JSR, no parameter transport, no prologue. @@ -1681,6 +1710,21 @@ impl LoweringContext { self.emit(IrOp::Call(Some(t), name.clone(), arg_temps)); t } + Expr::ButtonEdge(player, button, released, _) => { + let player_index = match player { + Some(Player::P2) => 1u8, + _ => 0u8, + }; + let mask = button_mask(button); + let dest = self.fresh_temp(); + self.emit(IrOp::ReadInputEdge { + dest, + player: player_index, + mask, + released: *released, + }); + dest + } Expr::ButtonRead(player, button, _) => { // Button reads: read the input byte, mask with the button bit. // Player 1 reads from $01, player 2 reads from $08. diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 9511fb5..1557756 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -305,6 +305,40 @@ pub enum IrOp { /// currently-playing SFX tail. StopMusic, + /// `rand8()` — pull the next 8 bits from the runtime PRNG into + /// `dest`. Codegen emits a `JSR __rand8` and stores A. The + /// `__rand_used` marker is emitted so the linker splices + /// `gen_prng` into PRG ROM. + Rand8(IrTemp), + /// `rand16()` — pull the next 16 bits from the runtime PRNG into + /// `(lo, hi)`. Emits `JSR __rand16` (which returns A=lo, X=hi) + /// and stores both bytes. + Rand16(IrTemp, IrTemp), + /// `seed_rand(x)` — install `(lo, hi)` as the new PRNG state. + /// Codegen loads the bytes into A/X and emits `JSR __rand_seed`. + SeedRand(IrTemp, IrTemp), + + /// `set_palette_brightness(level)` — write the PPU mask + /// emphasis / blanking bits that neslib calls `pal_bright`. The + /// runtime translates levels 0..8 into the appropriate `$2001` + /// mask byte. Codegen lowers to a JSR to `__set_palette_brightness` + /// and emits the `__palette_bright_used` marker. + SetPaletteBrightness(IrTemp), + + /// Edge-triggered input read: `p1.a.pressed` / `p1.a.released`. + /// `dest` receives a boolean (0 or the button mask) — set when + /// the button is pressed-but-was-not this frame (for + /// `.pressed`), or not-pressed-but-was last frame (for + /// `.released`). `player` is 0 for P1, 1 for P2. `mask` is + /// the button bit. `released` selects the released variant when + /// true, pressed when false. + ReadInputEdge { + dest: IrTemp, + player: u8, + mask: u8, + released: bool, + }, + // Source mapping SourceLoc(Span), } diff --git a/src/linker/debug_symbols.rs b/src/linker/debug_symbols.rs index 4ea6334..d0089bf 100644 --- a/src/linker/debug_symbols.rs +++ b/src/linker/debug_symbols.rs @@ -22,6 +22,50 @@ use super::LinkedRom; use crate::analyzer::VarAllocation; use crate::lexer::Span; +/// Render an FCEUX-compatible label file for the fixed PRG bank. +/// +/// FCEUX looks for `..nl` per-bank label files +/// in the same directory as the ROM, then for a `.ram.nl` +/// for RAM/zero-page labels. Each line in a bank file has the form +/// `$XXXX#label_name#`, where `$XXXX` is the CPU address inside the +/// bank window (matching whatever the fixed bank is mapped at at +/// runtime — for `NEScript`, `$C000-$FFFF`). RAM entries use +/// `$XXXX#name#` in the `.ram.nl` file; FCEUX doesn't namespace +/// these per-bank. +/// +/// Returns the bank-file contents. The caller is responsible for +/// writing it to disk under the correct per-bank name. RAM-label +/// output is produced by [`render_fceux_ram_nl`] below. +#[must_use] +pub fn render_fceux_nl(linked: &LinkedRom) -> String { + let mut out = String::new(); + let sorted: BTreeMap<&String, &u16> = linked.labels.iter().collect(); + for (label, &&cpu_addr) in &sorted { + let Some(display_name) = mlb_symbol_name(label) else { + continue; + }; + // FCEUX expects CPU addresses inside the bank window. The + // fixed bank is always at $C000-$FFFF, which is what the + // codegen emits, so the address passes through unchanged. + let _ = writeln!(out, "${cpu_addr:04X}#{display_name}#"); + } + out +} + +/// Render an FCEUX-compatible RAM label file (`.ram.nl`). +/// Addresses are the analyzer's variable allocations (zero page +/// and main RAM) in ascending order. +#[must_use] +pub fn render_fceux_ram_nl(var_allocations: &[VarAllocation]) -> String { + let mut out = String::new(); + let mut vars: Vec<&VarAllocation> = var_allocations.iter().collect(); + vars.sort_by_key(|a| a.address); + for var in vars { + let _ = writeln!(out, "${:04X}#{}#", var.address, var.name); + } + out +} + /// Render a Mesen-compatible `.mlb` symbol file from a /// [`LinkedRom`]. /// diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 0be67d0..cb30976 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -2,7 +2,9 @@ mod debug_symbols; #[cfg(test)] mod tests; -pub use debug_symbols::{render_dbg, render_mlb, render_source_map}; +pub use debug_symbols::{ + render_dbg, render_fceux_nl, render_fceux_ram_nl, render_mlb, render_source_map, +}; use std::collections::HashMap; @@ -425,6 +427,13 @@ impl Linker { total_banks, )); + // Seed the PRNG state. Only emitted when the IR codegen + // dropped the `__rand_used` marker — programs without PRNG + // keep their reset path byte-identical. + if has_label(user_code, "__rand_used") { + all_instructions.extend(runtime::gen_prng_init()); + } + // Whether the program produces any visual output. True if // the user declared a palette / sprite / background, or if // user code contains the `__oam_used` marker (i.e. draws). @@ -544,6 +553,22 @@ impl Linker { all_instructions.extend(runtime::gen_divide()); } + // PRNG: splice the three entry points (`__rand8`, `__rand16`, + // `__rand_seed`) whenever the codegen dropped the `__rand_used` + // marker. Programs that never call `rand8()` / `rand16()` / + // `seed_rand()` skip this entirely. + let has_rand = has_label(user_code, "__rand_used"); + if has_rand { + all_instructions.extend(runtime::gen_prng()); + } + + // Palette brightness: splice `__set_palette_brightness` + // whenever `set_palette_brightness(level)` was called. + let has_palette_bright = has_label(user_code, "__palette_bright_used"); + if has_palette_bright { + all_instructions.extend(runtime::gen_palette_brightness()); + } + // Audio subsystem — linked in whenever user code touched // audio (detected via the `__audio_used` marker emitted by // the IR codegen). The driver body, period table, and @@ -670,6 +695,10 @@ impl Linker { let has_sprite_cycle = has_label(user_code, "__sprite_cycle_used"); let has_p1_input = has_label(user_code, "__p1_input_used"); let has_p2_input = has_label(user_code, "__p2_input_used"); + // `__edge_input_used` is dropped whenever any `p1.a.pressed` / + // `p1.a.released` site lowers. Tells the NMI to snapshot the + // previous-frame input byte before the new strobe. + let has_edge_input = has_label(user_code, "__edge_input_used"); all_instructions.extend(runtime::gen_nmi(runtime::NmiOptions { has_ppu_updates, has_audio, @@ -678,6 +707,7 @@ impl Linker { has_oam, has_p2_input, has_p1_input, + has_edge_input, })); // IRQ handler @@ -747,7 +777,21 @@ impl Linker { // the fixed bank — math runtime, audio tick, other state // handlers, etc. if switchable_banks.is_empty() { - builder.set_prg(prg); + // AxROM (mapper 7) maps a single 32 KB PRG page at + // $8000-$FFFF, so emulators expect PRG size in multiples + // of 32 KB. For single-bank AxROM we emit two 16 KB iNES + // banks: the first is 0xFF fill (maps at $8000-$BFFF when + // bank 0 is selected), and the second is our assembled + // fixed-bank code (maps at $C000-$FFFF). Bank-select + // writes still work — mapper 7's register picks the 32 KB + // page — but with a single PRG page the upper-half code + // is always visible. + if self.mapper == Mapper::AxROM { + let filler = vec![0xFF_u8; 16384]; + builder.set_prg_banks(vec![filler, prg]); + } else { + builder.set_prg(prg); + } } else { // Build the merged label table the bank assembler // needs. Includes both the cross-bank labels gathered diff --git a/src/main.rs b/src/main.rs index 4fe29f1..de9add2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use nescript::analyzer; use nescript::assets::{BackgroundData, PaletteData}; use nescript::errors::render_diagnostics; -use nescript::linker::{render_dbg, render_mlb, render_source_map, LinkedRom}; +use nescript::linker::{ + render_dbg, render_fceux_nl, render_fceux_ram_nl, render_mlb, render_source_map, LinkedRom, +}; use nescript::pipeline::{compile_source, CompileError, CompileOptions as PipelineOptions}; #[derive(Parser)] @@ -73,6 +75,16 @@ enum Cli { /// line records have something to point at. #[arg(long, value_name = "PATH")] dbg: Option, + + /// Emit FCEUX-compatible per-bank `.nl` label files and a + /// `.ram.nl` file next to the ROM. The argument is the + /// prefix; FCEUX appends `..nl` / `.ram.nl`. Unlike + /// `.dbg` (which Mesen/Mesen2 understand natively), the + /// `.nl` format is what FCEUX on Linux reads, and many + /// users still prefer FCEUX over Mesen for its lighter + /// footprint. + #[arg(long, value_name = "PREFIX")] + fceux_labels: Option, }, /// Type-check a source file without building Check { @@ -97,6 +109,7 @@ fn main() { symbols, source_map, dbg, + fceux_labels, } => { let output = output.unwrap_or_else(|| input.with_extension("nes")); match compile( @@ -112,6 +125,7 @@ fn main() { symbols: symbols.clone(), source_map: source_map.clone(), dbg: dbg.clone(), + fceux_labels: fceux_labels.clone(), }, ) { Ok(rom) => { @@ -376,6 +390,7 @@ struct CompileOptions { symbols: Option, source_map: Option, dbg: Option, + fceux_labels: Option, } fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result, ()> { @@ -485,6 +500,33 @@ fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result..nl` per-bank and + // `.ram.nl` for RAM symbols. NEScript's fixed bank + // is always the last physical bank in the ROM, so for a + // single-bank NROM layout the index is 0; for banked ROMs + // it's `total_banks - 1`. We derive it from the linker's + // reported fixed-bank file offset: (offset - 16) / + // PRG_BANK_SIZE. + const PRG_BANK_SIZE: usize = 16384; + let bank_index = out.link_result.fixed_bank_file_offset.saturating_sub(16) / PRG_BANK_SIZE; + let bank_nl = render_fceux_nl(&out.link_result); + let bank_path = prefix.with_extension(format!("{bank_index}.nl")); + std::fs::write(&bank_path, bank_nl).map_err(|e| { + eprintln!( + "error: failed to write FCEUX bank label file {}: {e}", + bank_path.display() + ); + })?; + let ram_nl = render_fceux_ram_nl(&out.analysis.var_allocations); + let ram_path = prefix.with_extension("ram.nl"); + std::fs::write(&ram_path, ram_nl).map_err(|e| { + eprintln!( + "error: failed to write FCEUX RAM label file {}: {e}", + ram_path.display() + ); + })?; + } Ok(out.rom) } diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 6e1dade..8f175ef 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -574,6 +574,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { } IrOp::LoadVarHi(_, _) | IrOp::ReadInput(_, _) + | IrOp::ReadInputEdge { .. } | IrOp::WaitFrame | IrOp::CycleSprites | IrOp::Transition(_) @@ -582,9 +583,18 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { | IrOp::PlaySfx(_) | IrOp::StartMusic(_) | IrOp::StopMusic + | IrOp::Rand8(_) + | IrOp::Rand16(_, _) | IrOp::SetPalette(_) | IrOp::LoadBackground(_) | IrOp::SourceLoc(_) => {} + IrOp::SeedRand(lo, hi) => { + used.insert(*lo); + used.insert(*hi); + } + IrOp::SetPaletteBrightness(level) => { + used.insert(*level); + } } } @@ -614,9 +624,12 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::CmpLtEq(d, _, _) | IrOp::CmpGtEq(d, _, _) | IrOp::ArrayLoad(d, _, _) => Some(*d), + IrOp::Rand16(d, _) => Some(*d), IrOp::Call(dest, _, _) => *dest, IrOp::ReadInput(d, _) => Some(*d), + IrOp::ReadInputEdge { dest, .. } => Some(*dest), IrOp::Peek(d, _) => Some(*d), + IrOp::Rand8(d) => Some(*d), IrOp::StoreVar(_, _) | IrOp::StoreVarHi(_, _) | IrOp::ArrayStore(_, _, _) @@ -632,6 +645,8 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::PlaySfx(_) | IrOp::StartMusic(_) | IrOp::StopMusic + | IrOp::SeedRand(_, _) + | IrOp::SetPaletteBrightness(_) | IrOp::SetPalette(_) | IrOp::LoadBackground(_) | IrOp::SourceLoc(_) => None, diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 265ca1f..175d3c6 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -235,6 +235,19 @@ pub enum Mapper { MMC1, UxROM, MMC3, + /// `AxROM` (mapper 7). Single-screen mirroring, up to 256 KB PRG + /// bankswitched in 32 KB pages via one write to `$8000-$FFFF`. + /// Register layout: bits 0-2 select the 32 KB PRG bank, bit 4 + /// selects single-screen nametable (0 = lower, 1 = upper). + /// `Mirroring::Horizontal` → lower-screen, `Mirroring::Vertical` + /// → upper-screen in the initial write at reset. + AxROM, + /// `CNROM` (mapper 3). Fixed 32 KB PRG, 8 KB CHR bankswitching + /// via one write to `$8000-$FFFF`. Supports up to ~2 MB of CHR + /// ROM though most commercial carts stopped at 32 KB. Useful + /// for games that want static PRG but swap entire tile sheets + /// per screen / level. + CNROM, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -376,6 +389,13 @@ pub enum Expr { UnaryOp(UnaryOp, Box, Span), Call(String, Vec, Span), ButtonRead(Option, String, Span), + /// Edge-triggered button read: `p1.button.a.pressed` or + /// `p1.button.a.released`. The final boolean says which edge + /// (true = released, false = pressed) so a single variant + /// covers both cases. The analyzer and lowering read + /// `PREV_INPUT_P1` / `PREV_INPUT_P2` at the read site and + /// XOR against the current byte to compute the edge. + ButtonEdge(Option, String, bool, Span), ArrayLiteral(Vec, Span), Cast(Box, NesType, Span), /// Struct literal: `Name { field1: expr, field2: expr, ... }`. @@ -405,6 +425,7 @@ impl Expr { | Self::UnaryOp(_, _, s) | Self::Call(_, _, s) | Self::ButtonRead(_, _, s) + | Self::ButtonEdge(_, _, _, s) | Self::ArrayLiteral(_, s) | Self::Cast(_, _, s) | Self::StructLiteral(_, _, s) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b5b5799..e7076f1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -369,13 +369,17 @@ impl Parser { "MMC1" => Mapper::MMC1, "UxROM" => Mapper::UxROM, "MMC3" => Mapper::MMC3, + "AxROM" => Mapper::AxROM, + "CNROM" => Mapper::CNROM, _ => { return Err(Diagnostic::error( ErrorCode::E0201, format!("unknown mapper '{val}'"), self.current_span(), ) - .with_help("supported mappers: NROM, MMC1, UxROM, MMC3")); + .with_help( + "supported mappers: NROM, MMC1, UxROM, MMC3, AxROM, CNROM", + )); } }; } @@ -3152,14 +3156,34 @@ impl Parser { let span = self.current_span(); self.advance(); - // Check for button.X (player 1 default) + // Check for button.X or button.X.pressed/.released if name == "button" && *self.peek() == TokenKind::Dot { self.advance(); let (button, _) = self.expect_name()?; + if *self.peek() == TokenKind::Dot { + self.advance(); + if let TokenKind::Ident(edge) = self.peek().clone() { + if edge == "pressed" || edge == "released" { + self.advance(); + return Ok(Expr::ButtonEdge( + None, + button, + edge == "released", + span, + )); + } + } + return Err(Diagnostic::error( + ErrorCode::E0201, + "expected 'pressed' or 'released' after button name", + self.current_span(), + )); + } return Ok(Expr::ButtonRead(None, button, span)); } - // Check for p1.button.X / p2.button.X + // Check for p1.button.X / p2.button.X, optionally + // followed by .pressed / .released for edge-triggered. if (name == "p1" || name == "p2") && *self.peek() == TokenKind::Dot { self.advance(); // Expect 'button' @@ -3173,6 +3197,25 @@ impl Parser { } else { Some(Player::P2) }; + if *self.peek() == TokenKind::Dot { + self.advance(); + if let TokenKind::Ident(edge) = self.peek().clone() { + if edge == "pressed" || edge == "released" { + self.advance(); + return Ok(Expr::ButtonEdge( + player, + button, + edge == "released", + span, + )); + } + } + return Err(Diagnostic::error( + ErrorCode::E0201, + "expected 'pressed' or 'released' after button name", + self.current_span(), + )); + } return Ok(Expr::ButtonRead(player, button, span)); } } diff --git a/src/rom/mod.rs b/src/rom/mod.rs index f8d5302..8d3520a 100644 --- a/src/rom/mod.rs +++ b/src/rom/mod.rs @@ -274,6 +274,8 @@ pub fn mapper_number(mapper: Mapper) -> u8 { Mapper::NROM => 0, Mapper::MMC1 => 1, Mapper::UxROM => 2, + Mapper::CNROM => 3, Mapper::MMC3 => 4, + Mapper::AxROM => 7, } } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ebe2d57..d97fd6e 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -198,6 +198,29 @@ pub const AUDIO_TRIANGLE_COUNTER: u16 = 0x07F5; pub const AUDIO_SFX_PITCH_PTR_LO: u16 = 0x07F6; pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7; +/// Previous-frame copies of the two controller bytes. Populated +/// by the NMI handler *after* the current-frame shift loop, so +/// `curr & ~prev` ("just pressed") and `prev & ~curr` +/// ("just released") compile to a single AND chain at the read +/// site. Only touched by programs that reference `p1.button.pressed` +/// / `.released` (detected via the `__edge_input_used` marker +/// label); programs without edge-triggered reads leave these +/// two bytes free. +pub const PREV_INPUT_P1: u16 = 0x07E6; +pub const PREV_INPUT_P2: u16 = 0x07E7; + +/// PRNG state — two bytes holding a 16-bit xorshift state. +/// Only touched by the `__rand8` / `__rand16` / `__rand_seed` +/// runtime routines, which the linker splices in whenever user +/// code contains the `__rand_used` marker label. The reset path +/// seeds the state to a non-zero constant (0xACE1) so the very +/// first `rand8()` call returns a useful value even without an +/// explicit `seed_rand`. Programs that never touch PRNG skip +/// both the state init and the routines, leaving these two +/// bytes free for the analyzer to allocate over. +pub const PRNG_STATE_LO: u16 = 0x07EA; +pub const PRNG_STATE_HI: u16 = 0x07EB; + /// Generate the NES hardware initialization sequence. /// This runs at RESET and sets up the hardware before user code. /// @@ -399,6 +422,14 @@ pub struct NmiOptions { /// zero cycles for input sampling (~100 cycles: the 88-cycle /// body plus the ~12-cycle strobe and loop scaffold). pub has_p1_input: bool, + /// When true, snapshot the previous-frame controller byte + /// into `PREV_INPUT_P1` / `PREV_INPUT_P2` *before* the + /// new-frame strobe-and-shift loop. This is the extra state + /// the IR codegen's `ReadInputEdge` op reads to compute + /// `p1.a.pressed` / `.released`. Programs that don't + /// reference edge-triggered input leave this off and the + /// two snapshot stores disappear. + pub has_edge_input: bool, } #[must_use] @@ -411,6 +442,7 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { has_oam, has_p2_input, has_p1_input, + has_edge_input, } = opts; let mut out = Vec::new(); @@ -520,6 +552,23 @@ pub fn gen_nmi(opts: NmiOptions) -> Vec { // actually uses. Programs that touch no `button.*` at all skip // the whole block. if has_p1_input || has_p2_input { + // Before strobing: snapshot the previous frame's input + // byte into main RAM so edge-triggered reads + // (`p1.a.pressed` / `.released`) can compute + // `curr & ~prev` / `prev & ~curr` at the user-code read + // site. Gated on `has_edge_input` so programs without + // edge reads skip the two (or four) store bytes and + // leave the two PREV_INPUT slots free for the analyzer. + if has_edge_input { + if has_p1_input { + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_INPUT_P1))); + out.push(Instruction::new(STA, AM::Absolute(PREV_INPUT_P1))); + } + if has_p2_input { + out.push(Instruction::new(LDA, AM::ZeroPage(ZP_INPUT_P2))); + out.push(Instruction::new(STA, AM::Absolute(PREV_INPUT_P2))); + } + } // Strobe: write 1 then 0 to $4016 so both port latches // capture the current button state. out.push(Instruction::new(LDA, AM::Immediate(0x01))); @@ -1562,6 +1611,225 @@ pub fn gen_divide() -> Vec { out } +/// Generate the runtime PRNG routines: `__rand8`, `__rand16`, and +/// `__rand_seed`. Spliced in by the linker only when user code +/// contains the `__rand_used` marker label (emitted by the IR +/// codegen whenever `rand8()`, `rand16()`, or `seed_rand()` is +/// referenced). Programs that never touch the PRNG pay zero ROM +/// or cycle cost. +/// +/// Algorithm: a 16-bit xorshift with the canonical `(7, 9, 8)` +/// parameters that produces a full 65535-cycle period from any +/// non-zero seed. The state lives in main RAM at +/// `PRNG_STATE_LO`/`PRNG_STATE_HI`; the reset path seeds it to +/// `0xACE1` (a classic xorshift16 nonzero seed) so the first +/// `rand8()` call returns a useful value without requiring an +/// explicit `seed_rand`. +/// +/// ## Entry points +/// +/// - `__rand8`: advances the state once, returns `A` = new low byte. +/// Clobbers A, X, flags. ~40 cycles. +/// - `__rand16`: advances twice, returns `A` = lo byte of the +/// second draw, `X` = hi byte. Clobbers A, X, flags. ~80 cycles. +/// - `__rand_seed`: consumes `A` = low byte, `X` = high byte of the +/// new seed and writes them to the state slots. Does not advance. +/// Callers are responsible for not seeding with zero (the PRNG +/// gets stuck), but the builtin lowering forces a `| 1` on the +/// low byte to sidestep the common `seed = frame_counter` case. +#[must_use] +pub fn gen_prng() -> Vec { + let mut out = Vec::new(); + + // ── __rand8 ────────────────────────────────────────────── + // xorshift16 step: + // state ^= state << 7 (A-register shift) + // state ^= state >> 9 (byte swap + right shift 1) + // state ^= state << 8 (swap: new_lo = hi, new_hi = hi ^ lo) + // Returns the new low byte in A. + out.push(Instruction::new(NOP, AM::Label("__rand8".into()))); + + // --- state ^= state << 7 --- + // 7 left shifts of a 16-bit value is equivalent to a 1-bit + // right rotation with fill-from-low-bit-of-hi, which the + // 6502 can do with a bit of carry juggling. Simpler: shift + // the whole 16-bit state left once and XOR back into itself, + // seven times. But for size we use the rotate trick: + // LDA hi : LSR A : LDA lo : ROR A : STA hi : ... no wait, + // let's just do it the straightforward way: 7 ASLs of the + // 16-bit state folded into a XOR. + // + // Clearer and smaller: compute tmp = state << 7 by: + // tmp_lo = lo << 7 = (lo & 1) << 7 [only bit 0 survives] + // tmp_hi = (hi << 7) | (lo >> 1) [hi's bit 0 is lo's bit 1] + // Actually, <<7 on 16-bit: bits 0..8 of input become bits 7..15 of output. + // output bit 7 = input bit 0 (was lo bit 0) + // output bits 8..14 = input bits 1..7 (was lo bits 1..7) + // output bit 15 = input bit 8 (was hi bit 0) + // + // Equivalently: tmp_lo = lo << 7, tmp_hi = (lo >> 1) | (hi << 7). + // Then state ^= tmp. + // + // Since tmp_lo has only bit 7 possibly set, and tmp_hi has + // bits 0..6 from lo and bit 7 from hi bit 0: + // + // LDA PRNG_STATE_LO ; A = lo + // LSR A ; A = lo >> 1, C = lo bit 0 + // ROR PRNG_STATE_LO ; no — we need to NOT change state yet + // + // Simpler: do the full algorithm using a scratch copy. + // + // For brevity and cycle cost we use a different but equivalent + // LFSR: a Galois-configuration 16-bit LFSR with polynomial + // 0x002D (taps at 0, 2, 3, 5) which also has a full period of + // 65535 from any non-zero seed. The advantage is a very small + // step: one shift and a conditional XOR with two bytes. + // + // Step: + // carry = state & 1 + // state >>= 1 + // if carry: state ^= 0xB400 + // + // The 0xB400 tap mask comes from the standard 16-bit LFSR + // polynomial x^16 + x^14 + x^13 + x^11 + 1 reversed for + // right-shift Galois form. It has full 65535 period. + + // LSR the high byte first, then ROR the low byte, so the + // low-bit-of-low-byte ends up in carry. + out.push(Instruction::new(LSR, AM::Absolute(PRNG_STATE_HI))); + out.push(Instruction::new(ROR, AM::Absolute(PRNG_STATE_LO))); + // If the shifted-out bit was zero, skip the XOR. + out.push(Instruction::new( + BCC, + AM::LabelRelative("__rand8_done".into()), + )); + // state_hi ^= 0xB4 + out.push(Instruction::new(LDA, AM::Absolute(PRNG_STATE_HI))); + out.push(Instruction::new(EOR, AM::Immediate(0xB4))); + out.push(Instruction::new(STA, AM::Absolute(PRNG_STATE_HI))); + // (low tap byte is 0x00, nothing to XOR there) + out.push(Instruction::new(NOP, AM::Label("__rand8_done".into()))); + // Return with A = new state low byte. + out.push(Instruction::new(LDA, AM::Absolute(PRNG_STATE_LO))); + out.push(Instruction::implied(RTS)); + + // ── __rand16 ───────────────────────────────────────────── + // Advance twice and return A=lo, X=hi. Two draws so the + // caller gets 16 bits of entropy instead of two halves of + // the same internal step. + out.push(Instruction::new(NOP, AM::Label("__rand16".into()))); + out.push(Instruction::new(JSR, AM::Label("__rand8".into()))); + out.push(Instruction::new(JSR, AM::Label("__rand8".into()))); + // At this point A = state_lo after the second draw; we want + // to also return the high byte, which is state_hi. Stash the + // low byte, load hi into X, restore lo into A. + out.push(Instruction::implied(TAY)); // save lo in Y + out.push(Instruction::new(LDX, AM::Absolute(PRNG_STATE_HI))); + out.push(Instruction::implied(TYA)); // restore lo to A + out.push(Instruction::implied(RTS)); + + // ── __rand_seed ────────────────────────────────────────── + // Store A = lo, X = hi into the state. + out.push(Instruction::new(NOP, AM::Label("__rand_seed".into()))); + // Force bit 0 of the low byte high so a zero seed doesn't + // stick the LFSR. + out.push(Instruction::new(ORA, AM::Immediate(0x01))); + out.push(Instruction::new(STA, AM::Absolute(PRNG_STATE_LO))); + out.push(Instruction::new(STX, AM::Absolute(PRNG_STATE_HI))); + out.push(Instruction::implied(RTS)); + + out +} + +/// Generate the `__set_palette_brightness` runtime routine. +/// Spliced in by the linker only when user code contains the +/// `__palette_bright_used` marker label. +/// +/// The input level (passed in A) is mapped to a PPU mask byte: +/// - `0` — everything off (blank screen) +/// - `1..3` — fade-out via PPU mask "darken" bits (R/G/B emphasis), +/// which darken the visible colour range by a few notches. +/// - `4` (normal) — `$1E`, sprites + background, no emphasis +/// - `5..7` — progressive emphasis bits to push towards white +/// - `8` — all three emphasis bits set (max "bright") +/// +/// The mapping is a 9-entry table indexed by the clamped level. +/// Levels above 8 clamp to 8 (max brightness). +#[must_use] +pub fn gen_palette_brightness() -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new( + NOP, + AM::Label("__set_palette_brightness".into()), + )); + // Clamp level (in A) to 8. If A >= 9, force A = 8. + out.push(Instruction::new(CMP, AM::Immediate(9))); + out.push(Instruction::new( + BCC, + AM::LabelRelative("__bright_clamped".into()), + )); + out.push(Instruction::new(LDA, AM::Immediate(8))); + out.push(Instruction::new(NOP, AM::Label("__bright_clamped".into()))); + // Dispatch by level through a compare-and-set tree. Each level + // maps to a specific `$2001` mask byte: + // 0: $00 — all rendering off (blank) + // 1: $01 — greyscale flag (dim-looking) + // 2: $1E — normal sprites + bg + // 3: $3E — +red emphasis + // 4: $5E — +green emphasis + // 5: $9E — +blue emphasis + // 6: $7E — +red+green (yellow tint) + // 7: $BE — +red+blue (magenta tint) + // 8: $FE — all three emphasis bits (brightest) + // The table is short enough that the linear CPX tree is + // comparable in ROM cost to a proper table-indexed load and + // avoids needing a Label-based AbsoluteX mode in the assembler. + out.push(Instruction::implied(TAX)); + let entries: [(u8, u8); 9] = [ + (0, 0x00), + (1, 0x01), + (2, 0x1E), + (3, 0x3E), + (4, 0x5E), + (5, 0x9E), + (6, 0x7E), + (7, 0xBE), + (8, 0xFE), + ]; + for (level, mask) in entries { + out.push(Instruction::new(CPX, AM::Immediate(level))); + out.push(Instruction::new( + BNE, + AM::LabelRelative(format!("__bright_not_{level}")), + )); + out.push(Instruction::new(LDA, AM::Immediate(mask))); + out.push(Instruction::new(STA, AM::Absolute(PPU_MASK))); + out.push(Instruction::implied(RTS)); + out.push(Instruction::new( + NOP, + AM::Label(format!("__bright_not_{level}")), + )); + } + // Fall-through (shouldn't hit): leave PPU_MASK untouched. + out.push(Instruction::implied(RTS)); + out +} + +/// Emit the reset-time PRNG state seed. Spliced into the reset +/// path whenever `gen_prng` is linked in, so the first `rand8()` +/// call returns a useful value even without an explicit +/// `seed_rand`. The seed `0xACE1` is the classic nonzero +/// xorshift16 seed. +#[must_use] +pub fn gen_prng_init() -> Vec { + vec![ + Instruction::new(LDA, AM::Immediate(0xE1)), + Instruction::new(STA, AM::Absolute(PRNG_STATE_LO)), + Instruction::new(LDA, AM::Immediate(0xAC)), + Instruction::new(STA, AM::Absolute(PRNG_STATE_HI)), + ] +} + // ─── Bank switching ──────────────────────────────────────────────── // // NEScript supports bank switching for MMC1, UxROM, and MMC3. The @@ -1609,17 +1877,15 @@ pub fn gen_mapper_init( Mapper::MMC1 => gen_mmc1_init(mirroring), Mapper::UxROM => gen_uxrom_init(total_prg_banks), Mapper::MMC3 => gen_mmc3_init(mirroring), + Mapper::AxROM => gen_axrom_init(mirroring), + Mapper::CNROM => gen_cnrom_init(), }; // Initialize ZP_BANK_CURRENT to the fixed bank index for any - // banked mapper. The trampoline emitted by - // `gen_bank_trampoline` reads this slot to decide which bank - // to restore after a cross-bank call, so it has to be a - // sensible value from the very first call. Without this the - // RAM-clear leaves it at $00, which would put bank 0 at - // $8000 instead of the fixed bank after a fixed-bank caller's - // first cross-bank call — a behavior change vs. the pre- - // banked-banked codegen that some examples rely on. - if mapper != Mapper::NROM && total_prg_banks > 0 { + // banked mapper. CNROM and AxROM don't use per-function bank + // trampolines the same way MMC1/UxROM/MMC3 do — CNROM has a + // fixed 32 KB PRG, and AxROM swaps 32 KB pages as one unit — + // so they skip the `ZP_BANK_CURRENT` seed. + if matches!(mapper, Mapper::MMC1 | Mapper::UxROM | Mapper::MMC3) && total_prg_banks > 0 { #[allow(clippy::cast_possible_truncation)] let fixed_bank_index = (total_prg_banks - 1) as u8; out.push(Instruction::new(LDA, AM::Immediate(fixed_bank_index))); @@ -1628,6 +1894,46 @@ pub fn gen_mapper_init( out } +/// `AxROM` (mapper 7) reset. The `AxROM` register is a single latch +/// at `$8000-$FFFF`: +/// bits 0-2 select the 32 KB PRG bank mapped at `$8000-$FFFF` +/// bit 4 selects single-screen nametable (0 = lower, 1 = upper) +/// +/// We write `0` at reset so the ROM starts executing from bank 0 +/// with the lower nametable active. Some `AxROM` boards (Battletoads, +/// Wizards & Warriors) are bus-conflict-prone so the write has to +/// hit a ROM byte that matches; the simplest safe approach is to +/// put a `0x00` byte at a known ROM address and STA there. For +/// `NEScript`'s usage pattern (single PRG bank → mapper 0-equivalent +/// layout) this is good enough for the demo to boot. +fn gen_axrom_init(mirroring: Mirroring) -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__axrom_init".into()))); + // Start with the lower or upper single-screen nametable active. + // `Mirroring::Horizontal` → lower-screen (bit 4 = 0); `Vertical` + // → upper-screen (bit 4 = 1). This keeps the existing mirroring + // enum useful even though AxROM has no true horizontal/vertical + // modes. + let init_reg: u8 = match mirroring { + Mirroring::Horizontal => 0x00, + Mirroring::Vertical => 0x10, + }; + out.push(Instruction::new(LDA, AM::Immediate(init_reg))); + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out +} + +/// CNROM (mapper 3) reset. Fixed 32 KB PRG, 8 KB CHR bankswitching +/// via one write to `$8000-$FFFF`. At power-on the CHR bank is +/// undefined on some boards, so we explicitly write bank 0. +fn gen_cnrom_init() -> Vec { + let mut out = Vec::new(); + out.push(Instruction::new(NOP, AM::Label("__cnrom_init".into()))); + out.push(Instruction::new(LDA, AM::Immediate(0x00))); + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out +} + /// MMC1 reset: pulse the reset bit, then write the control register. /// Control-register layout (5 bits, serialized LSB-first into any /// $8000-$FFFF address): @@ -1800,6 +2106,24 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec { out.push(Instruction::new(STA, AM::Absolute(0x8001))); out.push(Instruction::implied(RTS)); } + Mapper::AxROM => { + // AxROM: write the 32 KB PRG bank number to $8000-$FFFF. + // Bit 4 selects the single-screen nametable; we preserve + // it by ORing whatever was latched last (implicit 0 from + // reset). Our bank-switching model only moves between + // 32 KB pages, so multi-bank AxROM programs would need + // to split along 32 KB boundaries — more of a layout + // concern than a runtime one. + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out.push(Instruction::implied(RTS)); + } + Mapper::CNROM => { + // CNROM: writing A to `$8000-$FFFF` selects an 8 KB CHR + // bank. PRG is fixed so this routine is only useful for + // graphics swaps. + out.push(Instruction::new(STA, AM::Absolute(0x8000))); + out.push(Instruction::implied(RTS)); + } } out } diff --git a/tests/emulator/goldens/axrom_simple.audio.hash b/tests/emulator/goldens/axrom_simple.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/axrom_simple.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/axrom_simple.png b/tests/emulator/goldens/axrom_simple.png new file mode 100644 index 0000000..87d03a9 Binary files /dev/null and b/tests/emulator/goldens/axrom_simple.png differ diff --git a/tests/emulator/goldens/cnrom_simple.audio.hash b/tests/emulator/goldens/cnrom_simple.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/cnrom_simple.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/cnrom_simple.png b/tests/emulator/goldens/cnrom_simple.png new file mode 100644 index 0000000..87d03a9 Binary files /dev/null and b/tests/emulator/goldens/cnrom_simple.png differ diff --git a/tests/emulator/goldens/edge_input_demo.audio.hash b/tests/emulator/goldens/edge_input_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/edge_input_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/edge_input_demo.png b/tests/emulator/goldens/edge_input_demo.png new file mode 100644 index 0000000..e92f844 Binary files /dev/null and b/tests/emulator/goldens/edge_input_demo.png differ diff --git a/tests/emulator/goldens/palette_brightness_demo.audio.hash b/tests/emulator/goldens/palette_brightness_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/palette_brightness_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/palette_brightness_demo.png b/tests/emulator/goldens/palette_brightness_demo.png new file mode 100644 index 0000000..52d3e51 Binary files /dev/null and b/tests/emulator/goldens/palette_brightness_demo.png differ diff --git a/tests/emulator/goldens/prng_demo.audio.hash b/tests/emulator/goldens/prng_demo.audio.hash new file mode 100644 index 0000000..5f988a9 --- /dev/null +++ b/tests/emulator/goldens/prng_demo.audio.hash @@ -0,0 +1 @@ +a82b6ff5 132084 diff --git a/tests/emulator/goldens/prng_demo.png b/tests/emulator/goldens/prng_demo.png new file mode 100644 index 0000000..6015ecb Binary files /dev/null and b/tests/emulator/goldens/prng_demo.png differ diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 792ff20..c5fb4ae 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2970,3 +2970,271 @@ fn debug_build_emits_bounds_check_halt_routine() { "debug halt label is internal; should not leak into .mlb" ); } + +// ── PRNG / edge input / palette brightness / new mappers ── + +/// Locate the four-byte sequence `JSR label` within a ROM's assembled +/// fixed bank. The search walks the bytes rather than trusting the +/// linker label table so a miscompile that drops the JSR fails this +/// test even if the label is still resolved. +fn contains_jsr_to(rom: &[u8], target_cpu_addr: u16) -> bool { + let bytes = target_cpu_addr.to_le_bytes(); + rom.windows(3) + .any(|w| w[0] == 0x20 && w[1] == bytes[0] && w[2] == bytes[1]) +} + +#[test] +fn rand8_lowers_to_jsr_to_rand8() { + let source = r#" +game "RandDemo" { mapper: NROM } +var x: u8 = 0 +on frame { + x = rand8() +} +start Main +"#; + let rom = compile(source); + // The `__rand8` runtime routine is now linked into the ROM. + // Its label lands inside the fixed PRG bank at $C000-$FFFF. + // Build a fresh linker-level compile so we can scan the label + // table too, but the byte-level test is the authoritative one. + let info = rom::validate_ines(&rom).expect("should be valid iNES"); + assert_eq!(info.mapper, 0); + // Rebuild with the linker to read the label table and verify + // our byte scan hits the right JSR. + 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, + &[], + &[], + &[], + ); + let rand8_addr = *linked + .labels + .get("__rand8") + .expect("__rand_used marker should cause __rand8 to be linked in"); + assert!( + contains_jsr_to(&linked.rom, rand8_addr), + "rand8() should emit JSR __rand8 at its call site" + ); +} + +#[test] +fn rand_routines_omitted_without_use() { + // Sanity: a program that never calls rand8/rand16/seed_rand + // should not pay any bytes for the PRNG. + let source = r#" +game "NoRand" { mapper: NROM } +var x: u8 = 0 +on frame { x = x + 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, + &[], + &[], + &[], + ); + assert!( + !linked.labels.contains_key("__rand8"), + "PRNG routine should not be linked in when unused" + ); +} + +#[test] +fn set_palette_brightness_lowers_to_jsr() { + let source = r#" +game "FadeDemo" { mapper: NROM } +on frame { + set_palette_brightness(4) +} +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, + &[], + &[], + &[], + ); + let addr = *linked + .labels + .get("__set_palette_brightness") + .expect("set_palette_brightness should link __set_palette_brightness"); + assert!( + contains_jsr_to(&linked.rom, addr), + "set_palette_brightness should emit JSR __set_palette_brightness" + ); +} + +#[test] +fn edge_input_pressed_emits_prev_snapshot() { + // A program that reads `p1.button.a.pressed` should have the + // runtime snapshot the previous-frame input byte into + // `$07E6` inside the NMI. We verify the store sequence lands. + let source = r#" +game "EdgeDemo" { mapper: NROM } +var hit: u8 = 0 +on frame { + if p1.button.a.pressed { + hit += 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, + &[], + &[], + &[], + ); + // The NMI handler should STA `$07E6` (PREV_INPUT_P1). Scan + // the ROM bytes for the `STA abs $07E6` opcode sequence + // (0x8D 0xE6 0x07). + assert!( + linked + .rom + .windows(3) + .any(|w| w[0] == 0x8D && w[1] == 0xE6 && w[2] == 0x07), + "edge input should cause the NMI to save the previous-frame P1 byte to $07E6" + ); +} + +#[test] +fn axrom_rom_has_correct_header_and_size() { + let source = r#" +game "AxDemo" { mapper: AxROM } +var x: u8 = 0 +on frame { x += 1 } +start Main +"#; + let rom = compile_with_mapper(source); + let info = rom::validate_ines(&rom).expect("should be valid iNES"); + assert_eq!(info.mapper, 7, "AxROM is iNES mapper 7"); + // Two PRG banks = 32 KB = mapper 7's page size. + assert_eq!(info.prg_banks, 2, "AxROM should be 32 KB PRG"); +} + +#[test] +fn cnrom_rom_has_correct_header() { + let source = r#" +game "CnDemo" { mapper: CNROM } +var x: u8 = 0 +on frame { x += 1 } +start Main +"#; + let rom = compile_with_mapper(source); + let info = rom::validate_ines(&rom).expect("should be valid iNES"); + assert_eq!(info.mapper, 3, "CNROM is iNES mapper 3"); + assert_eq!(info.prg_banks, 1, "CNROM demo uses a single 16 KB PRG bank"); +} + +#[test] +fn unknown_button_edge_emits_diagnostic() { + // The analyzer should reject `p1.button.foo.pressed` with an + // error — silently compiling to "always false" would make + // typos invisible at runtime. + let source = r#" +game "BadEdge" { mapper: NROM } +on frame { + if p1.button.asdf.pressed { } +} +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("unknown button")), + "unknown button name inside edge-trigger should error; got {:?}", + analysis.diagnostics + ); +} + +#[test] +fn rand8_no_args_mismatch_errors() { + let source = r#" +game "BadRand" { mapper: NROM } +on frame { + var _x: u8 = rand8(1, 2) +} +start Main +"#; + let (program, diags) = nescript::parser::parse(source); + // Parser accepts; analyzer should reject. + assert!(diags.iter().all(|d| !d.is_error())); + let program = program.expect("parse should succeed"); + let analysis = analyzer::analyze(&program); + assert!( + analysis + .diagnostics + .iter() + .any(nescript::errors::Diagnostic::is_error), + "rand8 with arguments should error" + ); +}