1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-21 06:17:18 +00:00

compiler: PRNG / edge input / palette fade / AxROM / CNROM / FCEUX labels

Closes seven of the cc65/nesdoug parity gaps catalogued in
docs/future-work.md in a single pass. All of the new features are
gated on marker labels so programs that don't use them produce
byte-identical ROM output (every pre-existing committed .nes file
round-trips unchanged).

Language / runtime additions:
- `rand8()` / `rand16()` / `seed_rand(u16)` intrinsics backed by a
  16-bit Galois LFSR (~30 bytes of runtime, ~40 cycles per draw).
  Reset path seeds state to 0xACE1 so the first draw is useful even
  without explicit seeding.
- `p1.button.a.pressed` / `.released` edge-triggered input via a
  new ReadInputEdge IR op plus an NMI-side prev-frame snapshot into
  $07E6/$07E7, gated on the `__edge_input_used` marker.
- `set_palette_brightness(level)` builtin mapping levels 0..8 to
  PPU mask emphasis bytes (`$2001`) for neslib-style screen fades.
- `mapper: AxROM` (iNES 7) with automatic 32 KB PRG padding so
  emulators that enforce mapper-7's 32 KB page size boot cleanly.
- `mapper: CNROM` (iNES 3) with a reset-time CHR bank 0 select.
- `--fceux-labels <prefix>` CLI flag emitting per-bank `.nl` label
  files and a `.ram.nl` file for FCEUX's debugger.

Tests + examples:
- Five new example programs with committed .nes ROMs and
  pixel+audio goldens: prng_demo, edge_input_demo,
  palette_brightness_demo, axrom_simple, cnrom_simple.
- Seven integration tests covering JSR emission, the
  omitted-when-unused invariant, the NMI prev-input snapshot, the
  correct mapper numbers for AxROM/CNROM, and negative tests for
  unknown button names and bad rand8 arity.
- `is_intrinsic()` now runs in expression-position Call paths too,
  so `var x = rand8(1, 2)` errors at compile time instead of
  silently dropping the extra arguments.
This commit is contained in:
Claude 2026-04-18 18:13:18 +00:00
parent c96135fd86
commit 7507459787
No known key found for this signature in database
35 changed files with 1272 additions and 22 deletions

View file

@ -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

View file

@ -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

25
examples/axrom_simple.ne Normal file
View file

@ -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

BIN
examples/axrom_simple.nes Normal file

Binary file not shown.

24
examples/cnrom_simple.ne Normal file
View file

@ -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

BIN
examples/cnrom_simple.nes Normal file

Binary file not shown.

View file

@ -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

Binary file not shown.

View file

@ -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

Binary file not shown.

45
examples/prng_demo.ne Normal file
View file

@ -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

BIN
examples/prng_demo.nes Normal file

Binary file not shown.

View file

@ -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<String>) {
| Expr::BoolLiteral(_, _)
| Expr::Ident(_, _)
| Expr::FieldAccess(_, _, _)
| Expr::ButtonRead(_, _, _) => {}
| Expr::ButtonRead(_, _, _)
| Expr::ButtonEdge(_, _, _, _) => {}
}
}

View file

@ -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<IrTemp> {
..
} => 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<IrTemp> {
| 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],
}
}

View file

@ -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.

View file

@ -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),
}

View file

@ -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 `<rom-name>.<bank-index>.nl` per-bank label files
/// in the same directory as the ROM, then for a `<rom-name>.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 (`<rom>.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`].
///

View file

@ -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

View file

@ -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<PathBuf>,
/// Emit FCEUX-compatible per-bank `.nl` label files and a
/// `.ram.nl` file next to the ROM. The argument is the
/// prefix; FCEUX appends `.<bank>.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<PathBuf>,
},
/// 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<PathBuf>,
source_map: Option<PathBuf>,
dbg: Option<PathBuf>,
fceux_labels: Option<PathBuf>,
}
fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
@ -485,6 +500,33 @@ fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result<Vec<
eprintln!("error: failed to write dbg file {}: {e}", path.display());
})?;
}
if let Some(prefix) = opts.fceux_labels.as_ref() {
// FCEUX looks for `<prefix>.<bank-index>.nl` per-bank and
// `<prefix>.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)
}

View file

@ -574,6 +574,7 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
}
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<IrTemp>) {
| 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<IrTemp> {
| 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<IrTemp> {
| IrOp::PlaySfx(_)
| IrOp::StartMusic(_)
| IrOp::StopMusic
| IrOp::SeedRand(_, _)
| IrOp::SetPaletteBrightness(_)
| IrOp::SetPalette(_)
| IrOp::LoadBackground(_)
| IrOp::SourceLoc(_) => None,

View file

@ -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<Expr>, Span),
Call(String, Vec<Expr>, Span),
ButtonRead(Option<Player>, 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<Player>, String, bool, Span),
ArrayLiteral(Vec<Expr>, Span),
Cast(Box<Expr>, 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)

View file

@ -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));
}
}

View file

@ -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,
}
}

View file

@ -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<Instruction> {
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<Instruction> {
// 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<Instruction> {
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<Instruction> {
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<Instruction> {
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<Instruction> {
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<Instruction> {
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<Instruction> {
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<Instruction> {
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
}

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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"
);
}