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:
parent
c96135fd86
commit
7507459787
35 changed files with 1272 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
25
examples/axrom_simple.ne
Normal 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
BIN
examples/axrom_simple.nes
Normal file
Binary file not shown.
24
examples/cnrom_simple.ne
Normal file
24
examples/cnrom_simple.ne
Normal 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
BIN
examples/cnrom_simple.nes
Normal file
Binary file not shown.
42
examples/edge_input_demo.ne
Normal file
42
examples/edge_input_demo.ne
Normal 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
|
||||
BIN
examples/edge_input_demo.nes
Normal file
BIN
examples/edge_input_demo.nes
Normal file
Binary file not shown.
28
examples/palette_brightness_demo.ne
Normal file
28
examples/palette_brightness_demo.ne
Normal 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
|
||||
BIN
examples/palette_brightness_demo.nes
Normal file
BIN
examples/palette_brightness_demo.nes
Normal file
Binary file not shown.
45
examples/prng_demo.ne
Normal file
45
examples/prng_demo.ne
Normal 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
BIN
examples/prng_demo.nes
Normal file
Binary file not shown.
|
|
@ -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(_, _, _, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
src/main.rs
44
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<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
1
tests/emulator/goldens/axrom_simple.audio.hash
Normal file
1
tests/emulator/goldens/axrom_simple.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/axrom_simple.png
Normal file
BIN
tests/emulator/goldens/axrom_simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
1
tests/emulator/goldens/cnrom_simple.audio.hash
Normal file
1
tests/emulator/goldens/cnrom_simple.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/cnrom_simple.png
Normal file
BIN
tests/emulator/goldens/cnrom_simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
1
tests/emulator/goldens/edge_input_demo.audio.hash
Normal file
1
tests/emulator/goldens/edge_input_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/edge_input_demo.png
Normal file
BIN
tests/emulator/goldens/edge_input_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 939 B |
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/palette_brightness_demo.png
Normal file
BIN
tests/emulator/goldens/palette_brightness_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
tests/emulator/goldens/prng_demo.audio.hash
Normal file
1
tests/emulator/goldens/prng_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/prng_demo.png
Normal file
BIN
tests/emulator/goldens/prng_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue