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

review: tighten PRNG / void-intrinsic / FCEUX path handling

Follow-up cleanup on the cc65 parity batch. Addresses issues found
during a post-commit code review.

**Correctness fixes:**

- `rand8()` / `rand16()` at statement position (result discarded)
  were being eliminated by DCE because `op_dest` returned
  `Some(dest)` for Rand8/Rand16 even though the ops have a visible
  side effect — advancing the PRNG state. Now `op_dest` returns
  `None` for both, keeping the JSR regardless of liveness. New
  regression test `rand8_statement_survives_dce`.
- Void-only intrinsics (`poke`, `seed_rand`, `set_palette_brightness`)
  used in expression position (e.g. `var x = seed_rand(42)`) were
  panicking the linker with an unresolved `__ir_fn_X` label. The
  analyzer now emits E0203 with a clear message; new
  `void_intrinsic_in_expression_position_errors` test covers all
  three names.
- Statement-position `rand8()` / `rand16()` weren't lowered at all
  (they fell through to the default Call path). Now both lower to
  their IR op with a fresh temp that nothing reads; the JSR still
  runs so the PRNG state advances.
- `--fceux-labels foo.nes` was producing `foo.0.nl` because
  `PathBuf::with_extension` replaces instead of appends. Rewritten
  to literally append `.<bank>.nl` / `.ram.nl` to the OsString, so
  users get the FCEUX-expected `foo.nes.<bank>.nl` naming.
- Linker now asserts CNROM / AxROM don't accept user-declared
  switchable PRG banks — their page sizes don't fit the 16 KB per
  bank model, and silently producing a mis-sized ROM is worse than
  a loud panic.

**PRNG cleanup:**

- Removed the stream-of-consciousness comment block in `gen_prng`
  that described three abandoned algorithms before landing on the
  actual Galois LFSR.
- Simplified `__rand16` to a single JSR + LDX instead of two
  JSRs + TAY/TYA round-trip — a single shift already produces 16
  fresh bits, the doubled call just burned ~40 cycles. The golden
  PNG for `prng_demo` was regenerated to reflect the new sequence.
- Rewrote the `gen_prng` doc comment to accurately describe the
  algorithm as a Galois LFSR (it was mislabelled as xorshift).
- Rewrote the `gen_palette_brightness` doc comment with a proper
  table of level→mask mappings — the prior prose description
  didn't match the actual table values.

**Tests:**

- Three new unit tests in `linker::debug_symbols` covering the
  FCEUX `.nl` renderer: user-facing labels only, empty output when
  no user labels exist, and deterministic sorting in `.ram.nl`.
- Extended `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
  to cover AxROM + CNROM.
- Renumbered priority list in future-work.md after removing the
  shipped sections (J, K, N, parts of V and Y).

All 737 tests + 40/40 emulator goldens still green.
This commit is contained in:
Claude 2026-04-18 18:48:55 +00:00
parent a924dcc59c
commit f4968256f4
No known key found for this signature in database
13 changed files with 337 additions and 166 deletions

View file

@ -394,14 +394,11 @@ NMI-time write budget (~2273 cycles) is too tight for a full
nametable. RLE is the smaller first step — emit a `nametable` that
can declare `compression: rle` and decompress at swap time.
### J. Palette brightness / fade (ships today)
### J. Palette-fade follow-ups
`set_palette_brightness(level: u8)` is a builtin that maps the
0..8 level onto `$2001` PPU mask emphasis bits. See
`examples/palette_brightness_demo.ne` for an end-to-end demo.
The runtime `__set_palette_brightness` routine is spliced in only
when user code references the builtin. Follow-ups still worth
doing:
`set_palette_brightness(level: u8)` ships today (levels 0..8 mapped
onto `$2001` emphasis bits); `examples/palette_brightness_demo.ne`
exercises it. Two follow-ups are still worth doing:
- Blocking `fade_out(frames)` / `fade_in(frames)` helpers — today
users write them in user-space with a for-loop + `wait_frame`.
@ -409,16 +406,6 @@ doing:
- A brightness-LUT path that recolours the active palette in
addition to the emphasis bits, for non-NTSC-assumption fades.
### K. Edge-triggered input (ships today)
`p1.button.a.pressed` / `p1.button.a.released` (and the P2 variants)
report the rising / falling edge of the button relative to the
previous frame. Implementation: one IR op
(`ReadInputEdge { player, mask, released }`), two main-RAM bytes
(`$07E6/$07E7` for P1/P2 prev state), and a new NMI-side snapshot
of the current input byte before the next strobe, all gated on the
`__edge_input_used` marker. See `examples/edge_input_demo.ne`.
### L. Sprite 0 hit split-screen
`split(x, y)` is the neslib primitive for a fixed status bar above
@ -445,17 +432,6 @@ modifier for HUD sprites that must stay at low OAM slots — is the
cleaner user-facing API. Mentioned already under Open Design
Questions; bumping it into the active roadmap.
### N. Runtime PRNG (ships today)
`rand8()` / `rand16()` / `seed_rand(seed: u16)` are builtin
intrinsics backed by a 16-bit Galois LFSR (polynomial `0xB400`,
full 65535-cycle period from any non-zero seed). State lives in
main RAM at `$07EA/$07EB` and is seeded to `0xACE1` at reset so
the first draw is useful without explicit seeding. Routines +
seed init are gated on the `__rand_used` marker — programs that
never call any of the three pay zero bytes. See
`examples/prng_demo.ne`.
### O. DPCM / DMC sample playback
Already listed under Audio Pipeline. FamiStudio's DMC support
@ -518,16 +494,11 @@ three reads every program needs.
### V. Additional mappers
**Shipped:**
- **AxROM** (mapper 7) — single-screen mirroring, 32 KB PRG pages.
`mapper: AxROM` in `game { }`. Linker pads single-bank ROMs to
32 KB. See `examples/axrom_simple.ne`.
- **CNROM** (mapper 3) — fixed 32 KB PRG, 8 KB CHR bankswitching.
`mapper: CNROM`. See `examples/cnrom_simple.ne`. User-visible CHR
bank selection is still TODO — the reset-time init writes bank 0
and nothing else is exposed yet.
**Still TODO:**
AxROM (mapper 7) and CNROM (mapper 3) both ship today; see
`examples/axrom_simple.ne` and `examples/cnrom_simple.ne`. CNROM's
user-visible CHR bankswitching is still TODO — the reset-time init
writes bank 0 and nothing else is exposed yet, so CHR swaps
mid-frame aren't reachable from user source. The next set:
1. **GNROM / MHROM** (mapper 66). Combines AxROM-style PRG with
CNROM-style CHR banking. Another single-register mapper.
@ -558,13 +529,13 @@ Today the debug port is hardcoded to `$4800`. Expose
`mesen`, emit writes to `$4018` (Mesen's documented debug port)
and document the trace-log tool invocation in the debug docs.
### Y. FCEUX `.nl` / `.ld` label file output (ships today)
### Y. FCEUX `.ld` line-info follow-up
`--fceux-labels <prefix>` emits `<prefix>.<bank-index>.nl` for
each PRG bank plus `<prefix>.ram.nl` for RAM/zero-page labels.
Each bank line has the form `$XXXX#label_name#` which is what
FCEUX reads. Still TODO: a `.ld` line-info file that pairs with
the source map for proper line-level stepping in FCEUX.
`--fceux-labels <prefix>` ships today and emits
`<prefix>.<bank-index>.nl` + `<prefix>.ram.nl`. FCEUX also reads a
`.ld` line-info file for source-level stepping; wiring that up
against the existing source-map data would close the loop without
much code.
### Z. Explicit bank-placement hints on functions and data
@ -583,10 +554,7 @@ to a specific bank to avoid bank-switch cost on a hot path.
### Priority ranking
Already shipped: edge-triggered input (§K), PRNG (§N), palette
brightness (§J), AxROM + CNROM (§V), FCEUX labels (§Y).
Remaining order by user value:
Remaining gap items in order of user value:
1. `i16` (§A) — unblocks signed physics, metasprite offsets.
2. VRAM update buffer (§G) — unblocks HUDs, dialog, streaming.

View file

@ -1,9 +1,11 @@
// 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.
// Each frame draws four sprites at pseudorandom coordinates pulled
// from the Galois-LFSR `rand8()` intrinsic, plus one sprite whose
// (x, y) pair comes from a single `rand16()` draw. Exercises all
// three PRNG intrinsics (`rand8`, `rand16`, `seed_rand`) end-to-end.
// `seed_rand(0x1234)` on the first frame pins the sequence so the
// committed golden is deterministic.
game "PRNG Demo" {
mapper: NROM

Binary file not shown.

View file

@ -976,8 +976,21 @@ impl Analyzer {
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.
// typos and bad-arity calls don't slip past. Also
// reject the void-only intrinsics (`poke`,
// `seed_rand`, `set_palette_brightness`) at
// expression position so a typo like
// `var x = seed_rand(42)` fails at compile time
// instead of panicking the linker with an
// unresolved label.
self.check_intrinsic_args(name, args, *span);
if is_void_intrinsic(name) {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0203,
format!("`{name}` does not return a value; it can only be used as a statement"),
*span,
));
}
} else if self.function_signatures.contains_key(name) {
self.check_call_signature(name, args, *span);
}
@ -2582,6 +2595,15 @@ fn is_intrinsic(name: &str) -> bool {
)
}
/// True if `name` is an intrinsic that does not produce a usable
/// return value. These are valid only at statement position;
/// using them as an expression is a type error caught by the
/// analyzer so the codegen / linker never sees a stray
/// `Expr::Call` for one of them.
fn is_void_intrinsic(name: &str) -> bool {
matches!(name, "poke" | "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 {

View file

@ -1133,6 +1133,20 @@ impl LoweringContext {
let level = self.lower_expr(&args[0]);
self.emit(IrOp::SetPaletteBrightness(level));
}
// `rand8()` / `rand16()` at statement position —
// valid because they have side effects (advancing
// the PRNG state). The returned value is discarded
// by routing through a fresh temp that nothing
// reads; the JSR still runs so the state advances.
"rand8" if args.is_empty() => {
let t = self.fresh_temp();
self.emit(IrOp::Rand8(t));
}
"rand16" if args.is_empty() => {
let lo = self.fresh_temp();
let hi = self.fresh_temp();
self.emit(IrOp::Rand16(lo, hi));
}
_ => {
// Inline expansion at statement context
// splices either the return expression

View file

@ -742,4 +742,76 @@ mod tests {
"quotes + backslashes should be escaped in file record; got:\n{out}"
);
}
#[test]
fn fceux_nl_emits_user_facing_labels_sorted_by_name() {
let linked = make_linked(&[
("__reset", 0xC000),
("__nmi", 0xC100),
("__ir_fn_Main_frame", 0xC200),
("__ir_fn_helper", 0xC300),
("__ir_skip_42", 0xC180), // internal, must not appear
]);
let out = render_fceux_nl(&linked);
assert!(
out.contains("$C000#reset#"),
"reset entry point should be in .nl; got:\n{out}"
);
assert!(
out.contains("$C100#nmi#"),
"nmi entry point should be in .nl; got:\n{out}"
);
assert!(
out.contains("$C200#Main_frame#"),
"user frame handler should be in .nl; got:\n{out}"
);
assert!(
out.contains("$C300#helper#"),
"user function should be in .nl; got:\n{out}"
);
assert!(
!out.contains("__ir_skip_42"),
"internal skip labels should be filtered; got:\n{out}"
);
assert!(
!out.contains("__ir_skip"),
"no raw internal labels should leak; got:\n{out}"
);
}
#[test]
fn fceux_nl_empty_when_no_user_labels() {
// Only internal labels — the .nl file should be empty
// rather than containing every scaffold label.
let linked = make_linked(&[("__ir_skip_0", 0xC010), ("__ir_skip_1", 0xC020)]);
let out = render_fceux_nl(&linked);
assert!(out.is_empty(), "no user labels → empty .nl; got:\n{out}");
}
#[test]
fn fceux_ram_nl_sorted_by_address() {
let vars = vec![
VarAllocation {
name: "enemies".into(),
address: 0x0300,
size: 4,
},
VarAllocation {
name: "score".into(),
address: 0x0010,
size: 1,
},
VarAllocation {
name: "pos_x".into(),
address: 0x0020,
size: 1,
},
];
let out = render_fceux_ram_nl(&vars);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "$0010#score#");
assert_eq!(lines[1], "$0020#pos_x#");
assert_eq!(lines[2], "$0300#enemies#");
}
}

View file

@ -320,6 +320,18 @@ impl Linker {
"NROM does not support switchable PRG banks (got {} banks)",
switchable_banks.len()
);
// CNROM has a fixed 32 KB PRG — user-declared switchable PRG
// banks are meaningless. AxROM switches 32 KB pages as a
// unit, which the current 16 KB-per-bank trampoline model
// doesn't support cleanly. Reject both up front so a silent
// layout mismatch doesn't produce a subtly broken ROM.
assert!(
switchable_banks.is_empty() || !matches!(self.mapper, Mapper::CNROM | Mapper::AxROM),
"{:?} does not yet support switchable PRG banks (got {} banks); \
use MMC1/UxROM/MMC3 for per-function banking",
self.mapper,
switchable_banks.len()
);
self.link_banked_inner(
user_code,
sprites,

View file

@ -501,17 +501,30 @@ fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result<Vec<
})?;
}
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.
// FCEUX looks for `<rom-name>.<bank-index>.nl` per-bank and
// `<rom-name>.ram.nl` for RAM symbols, where `<rom-name>`
// is literally the ROM file name (typically something like
// `foo.nes`). We treat `--fceux-labels <prefix>` as exactly
// that stem and *append* the `.<bank>.nl` suffix rather than
// using `with_extension` (which would strip whatever came
// after the first `.`). This lets users pass either
// `--fceux-labels foo` (→ foo.0.nl, foo.ram.nl) or
// `--fceux-labels foo.nes` (→ foo.nes.0.nl, foo.nes.ram.nl,
// matching FCEUX's default search paths).
//
// The fixed bank is always the last physical bank in the
// ROM, so for a single-bank NROM layout the index is 0 and
// for banked ROMs it's `total_banks - 1`. We derive the
// index 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"));
let bank_path = {
let mut p = prefix.as_os_str().to_os_string();
p.push(format!(".{bank_index}.nl"));
PathBuf::from(p)
};
std::fs::write(&bank_path, bank_nl).map_err(|e| {
eprintln!(
"error: failed to write FCEUX bank label file {}: {e}",
@ -519,7 +532,11 @@ fn compile(input: &PathBuf, output: &Path, opts: &CompileOptions) -> Result<Vec<
);
})?;
let ram_nl = render_fceux_ram_nl(&out.analysis.var_allocations);
let ram_path = prefix.with_extension("ram.nl");
let ram_path = {
let mut p = prefix.as_os_str().to_os_string();
p.push(".ram.nl");
PathBuf::from(p)
};
std::fs::write(&ram_path, ram_nl).map_err(|e| {
eprintln!(
"error: failed to write FCEUX RAM label file {}: {e}",

View file

@ -624,12 +624,18 @@ 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),
// Rand8 / Rand16 have an observable side effect — advancing
// the PRNG state — so a statement-level call like
// `rand8()` (result discarded) must NOT be dropped by DCE.
// Returning `None` here keeps the JSR regardless of whether
// the dest temp is live; the dest is still referenced by
// `op_source_temps` so live expression-position uses still
// track correctly.
IrOp::Rand8(_) | IrOp::Rand16(_, _) => None,
IrOp::StoreVar(_, _)
| IrOp::StoreVarHi(_, _)
| IrOp::ArrayStore(_, _, _)

View file

@ -307,6 +307,8 @@ fn nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers() {
crate::parser::ast::Mapper::MMC1,
crate::parser::ast::Mapper::UxROM,
crate::parser::ast::Mapper::MMC3,
crate::parser::ast::Mapper::AxROM,
crate::parser::ast::Mapper::CNROM,
] {
let mut builder = RomBuilder::new(Mirroring::Horizontal);
builder.set_mapper(crate::rom::mapper_number(mapper));

View file

@ -1618,121 +1618,72 @@ pub fn gen_divide() -> Vec<Instruction> {
/// 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`.
/// ## Algorithm
///
/// A right-shifting Galois-configuration 16-bit LFSR with tap mask
/// `0xB400` on the high byte (polynomial `x^16 + x^14 + x^13 +
/// x^11 + 1`). From any non-zero seed it cycles through all 65535
/// non-zero 16-bit states before repeating. Per step:
///
/// ```text
/// carry = state & 1
/// state >>= 1
/// if carry: state ^= 0xB400
/// ```
///
/// State lives in main RAM at `PRNG_STATE_LO`/`PRNG_STATE_HI`; the
/// reset path seeds it to `0xACE1` so the first draw is useful
/// 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.
/// Clobbers A and flags. ~40 cycles.
/// - `__rand16`: advances once, returns `A` = new low byte,
/// `X` = new high byte — 16 bits from the same shift. Clobbers
/// A, X, and flags. ~50 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.
/// new seed and writes them to the state slots. Forces bit 0 of
/// the low byte high so a zero seed (which would stick the LFSR)
/// becomes `(1, 0)`.
#[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.
// One Galois LFSR step: shift the 16-bit state right, XOR the
// high byte with 0xB4 if the shifted-out bit was a 1. The
// low tap byte is 0x00 so only the high byte needs EORing.
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.
// LSR the high byte, ROR the low byte. Carry after ROR holds
// the old bit 0 of state_lo (the feedback bit).
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.
// If the feedback bit was zero, skip the tap 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.
// A single shift already produces 16 fresh bits (the full
// post-shift state). Call `__rand8` once and return both
// halves — A = lo, X = hi.
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.
// Store A = lo, X = hi into the state. Force bit 0 of the
// low byte high so a zero seed doesn't stick the LFSR.
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)));
@ -1745,16 +1696,32 @@ pub fn gen_prng() -> Vec<Instruction> {
/// 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")
/// Each level (passed in A, clamped to 0..=8) writes a specific
/// `$2001` PPU mask byte. The mapping is best thought of as a
/// "visual intensity dial" rather than a linear fade: the NES PPU
/// emphasis bits tint the display towards R / G / B rather than
/// truly darken or brighten, so the progression is a series of
/// distinct visual states. Levels 0 and 1 use low-level tricks
/// (rendering off, greyscale) to approximate black and dim; levels
/// 2-8 stack emphasis bits to shift the overall colour cast.
///
/// The mapping is a 9-entry table indexed by the clamped level.
/// Levels above 8 clamp to 8 (max brightness).
/// | level | mask | effect |
/// |-------|------|----------------------------------|
/// | 0 | $00 | rendering fully off (black) |
/// | 1 | $01 | greyscale only (washed out) |
/// | 2 | $1E | normal sprites + background |
/// | 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 |
///
/// Levels above 8 clamp to 8. Implemented as a CPX dispatch tree
/// (one `CPX; BNE; LDA; STA; RTS` block per level) rather than a
/// table lookup because the assembler doesn't expose a
/// Label-relative `AbsoluteX` mode; the nine-entry tree is ~60
/// bytes, comparable to a data-table + indexed-load path.
#[must_use]
pub fn gen_palette_brightness() -> Vec<Instruction> {
let mut out = Vec::new();
@ -1818,8 +1785,9 @@ pub fn gen_palette_brightness() -> Vec<Instruction> {
/// 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.
/// `seed_rand`. The seed `0xACE1` is an arbitrary non-zero 16-bit
/// value — any non-zero value works since the Galois LFSR's orbit
/// visits all 65535 non-zero states.
#[must_use]
pub fn gen_prng_init() -> Vec<Instruction> {
vec![
@ -2108,19 +2076,26 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
}
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.
// Bit 4 selects the single-screen nametable; bits 0-2
// select the PRG page. We don't preserve bit 4 here —
// user code that toggles between lower/upper nametables
// mid-run would need to OR it into A before calling.
// The linker's single-bank AxROM layout never actually
// invokes this routine today (no cross-bank trampolines
// for AxROM yet), but we emit it for parity with the
// other mappers.
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.
// bank. PRG is fixed so this routine is only ever useful
// for user-driven CHR swaps. Note: real CNROM boards
// have bus conflicts (CPU write vs ROM byte at target
// address), so a production API should route through a
// UxROM-style bus-conflict-safe table. No such API is
// exposed to user source yet — declaring more than one
// CHR bank in a CNROM program is currently a TODO.
out.push(Instruction::new(STA, AM::Absolute(0x8000)));
out.push(Instruction::implied(RTS));
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 841 B

Before After
Before After

View file

@ -3072,6 +3072,53 @@ start Main
);
}
#[test]
fn rand8_statement_survives_dce() {
// `rand8()` at statement position (result discarded) has a
// side effect — advancing the PRNG state — so DCE must not
// eliminate the JSR. Regression test: earlier the op_dest
// helper returned Some(dest) for Rand8, which let DCE drop
// statement-level draws where the temp was unused.
let source = r#"
game "StmtRand" { mapper: NROM }
on frame {
rand8()
rand16()
}
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 rand8_addr = *linked
.labels
.get("__rand8")
.expect("statement-level rand8() should still link in the PRNG routine");
assert!(
contains_jsr_to(&linked.rom, rand8_addr),
"rand8() statement should keep its JSR through the optimizer"
);
}
#[test]
fn set_palette_brightness_lowers_to_jsr() {
let source = r#"
@ -3216,6 +3263,40 @@ start Main
);
}
#[test]
fn void_intrinsic_in_expression_position_errors() {
// `seed_rand(...)`, `set_palette_brightness(...)`, and `poke(...)`
// don't produce values, so using them in expression position
// should be a compile-time error (not a linker panic later).
let cases = [
"var x: u8 = seed_rand(42)",
"var x: u8 = set_palette_brightness(4)",
"var x: u8 = poke(0x0200, 1)",
];
for frag in cases {
let source = format!(
r#"
game "BadVoid" {{ mapper: NROM }}
on frame {{
{frag}
}}
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("does not return a value")),
"void-intrinsic in expr position should be an error; fragment was `{frag}`, got {:?}",
analysis.diagnostics
);
}
}
#[test]
fn rand8_no_args_mismatch_errors() {
let source = r#"