1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-16 12:12:04 +00:00

codereview: address four residual concerns from the hardware review

- Analyzer: new `W0108` warning when an array's byte size exceeds
  256. The codegen lowers `arr[i]` to `LDA base,X` and the 6502's
  X register is 8 bits, so elements past byte 255 are unreachable.
  The old debug bounds check silently skipped arrays in that range;
  it now clamps the compare to 255 and the analyzer diagnoses the
  declaration up front.

- UxROM `__bank_select`: the routine previously wrote the bank
  number to a fixed `$FFF0`, which works on emulators that don't
  simulate bus conflicts (jsnes, Mesen permissive) but is broken
  on real hardware because a single ROM byte can't match every
  possible bank number. Fixed by `TAX; STA __bank_select_table,X`
  — the store lands at `table + bank_num`, whose ROM byte is
  exactly `bank_num`, so CPU bus = A = ROM = no conflict. New
  `LabelAbsoluteX` addressing-mode variant in the assembler
  resolves the table's base address through the existing fixup
  pass. The two existing UxROM example ROMs shift a few bytes
  but their goldens still match (jsnes is bus-conflict-permissive).

- Source maps: new `source_map_survives_aggressive_peephole_folding`
  regression test. The reviewer was worried peephole could drop
  `__src_<N>` labels and silently leave stale source-map entries.
  Peephole actually treats labels as block boundaries and never
  deletes them — the test pins that down by compiling a program
  tailored to trip every peephole fold and asserting every
  codegen-recorded source marker survives into the final linker
  label table.

- Frame-overrun counter: new `debug_frame_overrun_counter_reads_back_from_user_code`
  end-to-end test that proves the contract works: NMI emits
  `INC $07FF`, user `peek(0x07FF)` lowers to `LDA $07FF`, and the
  RAM allocator doesn't hand out `$07FF` to a user variable.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
Claude 2026-04-14 12:38:49 +00:00
parent 9b54ff83c0
commit c5297567d2
No known key found for this signature in database
11 changed files with 354 additions and 20 deletions

Binary file not shown.

Binary file not shown.

View file

@ -915,6 +915,34 @@ impl Analyzer {
.map(|(n, l)| (n.clone(), l.size)) .map(|(n, l)| (n.clone(), l.size))
.collect(); .collect();
let size = type_size_with(&var.var_type, &struct_sizes); let size = type_size_with(&var.var_type, &struct_sizes);
// Warn on arrays whose byte size exceeds 256: the codegen
// lowers `arr[i]` to `LDA base,X` (or `ZeroPageX`), and the
// 6502's X register is 8 bits, so elements whose byte
// offset is >= 256 are unreachable. For a `u8` array the
// safe max count is 256; for a `u16` array it's 128
// (since the codegen doesn't scale the index by element
// width — see the note in `emit_bounds_check`). This
// diagnostic replaces the previous silent-skip in the
// debug-mode bounds checker.
if let NesType::Array(_, _) = &var.var_type {
if size > 256 {
self.diagnostics.push(
Diagnostic::warning(
ErrorCode::W0108,
format!(
"array '{}' has byte size {size}, but the 6502's 8-bit X index can only reach the first 256 bytes — elements past that are unreachable",
var.name
),
var.span,
)
.with_help(
"shrink the array, split it across multiple smaller arrays, or use separate fields for each element".to_string(),
),
);
}
}
let Some(address) = self.allocate_ram(size, var.span) else { let Some(address) = self.allocate_ram(size, var.span) else {
// Allocation failed (E0301 already emitted) — still add the // Allocation failed (E0301 already emitted) — still add the
// symbol so that later references don't cascade into E0502, // symbol so that later references don't cascade into E0502,

View file

@ -1661,3 +1661,77 @@ fn analyze_fast_var_underscore_exempt() {
result.diagnostics result.diagnostics
); );
} }
#[test]
fn analyze_oversized_array_warns_w0108() {
// A u8 array with 300 elements has byte size 300 > 256. The
// codegen lowers `arr[i]` to `LDA base,X` with X 8-bit, so
// elements 256..299 are unreachable. W0108 should fire.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var big: u8[300]
on frame {
big[0] = 0
wait_frame
}
start Main
"#,
);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0108),
"oversized u8 array should emit W0108, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_boundary_size_256_array_ok() {
// A u8[256] exactly fills the 8-bit X register — every element
// is reachable. No W0108.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var big: u8[256]
on frame {
big[0] = 0
wait_frame
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0108),
"u8[256] should not emit W0108, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_small_array_never_warns_w0108() {
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var small: u8[16]
on frame {
small[0] = 0
wait_frame
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0108),
"small array should not emit W0108, got: {:?}",
result.diagnostics
);
}

View file

@ -182,6 +182,24 @@ impl Assembler {
self.output.push(0); // placeholder self.output.push(0); // placeholder
} }
} }
AddressingMode::LabelAbsoluteX(name) => {
// `STA label,X` style indexed store with a label-
// resolved base address. Encodes like `absolute,X`
// but the 16-bit address is patched in by the
// fixup pass, same as plain `Label` fixups.
if let Some(op) = opcodes::encode(inst.opcode, &AddressingMode::AbsoluteX(0)) {
self.output.push(op);
self.fixups.push(Fixup {
offset: self.output.len(),
label: name.clone(),
kind: FixupKind::Absolute,
});
self.output.push(0); // placeholder low byte
self.output.push(0); // placeholder high byte
} else {
panic!("opcode {:?} cannot target an absolute,X label", inst.opcode);
}
}
AddressingMode::SymbolLo(name) => { AddressingMode::SymbolLo(name) => {
if let Some(op) = opcodes::encode(inst.opcode, &AddressingMode::Immediate(0)) { if let Some(op) = opcodes::encode(inst.opcode, &AddressingMode::Immediate(0)) {
self.output.push(op); self.output.push(op);

View file

@ -79,6 +79,13 @@ pub enum AddressingMode {
// Pre-resolution symbolic forms // Pre-resolution symbolic forms
Label(String), Label(String),
LabelRelative(String), LabelRelative(String),
/// Absolute-X indexed form targeting a named label — resolves
/// to the 16-bit address of `label` at fix-up time, with the
/// instruction encoded as `absolute,X`. Used by `UxROM`'s
/// `__bank_select` to write into the bus-conflict table
/// (`STA __bank_select_table,X`) where the target address has
/// to be resolved by the linker.
LabelAbsoluteX(String),
SymbolLo(String), SymbolLo(String),
SymbolHi(String), SymbolHi(String),
@ -103,7 +110,11 @@ impl AddressingMode {
| Self::IndirectY(_) | Self::IndirectY(_)
| Self::Relative(_) => 1, | Self::Relative(_) => 1,
Self::Absolute(_) | Self::AbsoluteX(_) | Self::AbsoluteY(_) | Self::Indirect(_) => 2, Self::Absolute(_) | Self::AbsoluteX(_) | Self::AbsoluteY(_) | Self::Indirect(_) => 2,
Self::Label(_) | Self::LabelRelative(_) | Self::SymbolLo(_) | Self::SymbolHi(_) => 0, Self::Label(_)
| Self::LabelRelative(_)
| Self::LabelAbsoluteX(_)
| Self::SymbolLo(_)
| Self::SymbolHi(_) => 0,
// `Bytes` is the full emitted payload — the assembler // `Bytes` is the full emitted payload — the assembler
// skips the usual opcode byte for `NOP+Bytes` and writes // skips the usual opcode byte for `NOP+Bytes` and writes
// the raw vector, so the whole thing is operand. // the raw vector, so the whole thing is operand.
@ -125,7 +136,11 @@ impl AddressingMode {
Self::Absolute(v) | Self::AbsoluteX(v) | Self::AbsoluteY(v) | Self::Indirect(v) => { Self::Absolute(v) | Self::AbsoluteX(v) | Self::AbsoluteY(v) | Self::Indirect(v) => {
v.to_le_bytes().to_vec() v.to_le_bytes().to_vec()
} }
Self::Label(_) | Self::LabelRelative(_) | Self::SymbolLo(_) | Self::SymbolHi(_) => { Self::Label(_)
| Self::LabelRelative(_)
| Self::LabelAbsoluteX(_)
| Self::SymbolLo(_)
| Self::SymbolHi(_) => {
vec![] vec![]
} }
Self::Bytes(v) => v.clone(), Self::Bytes(v) => v.clone(),

View file

@ -452,13 +452,17 @@ impl<'a> IrCodeGen<'a> {
if size == 0 { if size == 0 {
return; return;
} }
// Anything >= 256 would overflow the u8 immediate; skip // Sizes > 256 skip the bounds check because the X register
// the check rather than emit a bogus compare. A proper // is 8 bits — any index the codegen can actually produce is
// 16-bit bounds check would need a two-byte compare // already in-bounds for a byte count that large. The
// against the high byte too. // analyzer's W0108 warning fires at declaration time so the
let Ok(size_u8) = u8::try_from(size) else { // user knows those elements are unreachable. Size == 256
// fits in a `CMP #$00` (trivially true), so we clamp to
// 255 — the tightest useful check — and treat 256+ the same.
let size_u8 = u8::try_from(size.min(255)).unwrap_or(255);
if size_u8 == 0 {
return; return;
}; }
// Use a short BCC over an unconditional JMP instead of a // Use a short BCC over an unconditional JMP instead of a
// plain `BCS __debug_halt`. A single BCS can only span 127 // plain `BCS __debug_halt`. A single BCS can only span 127
// bytes, and `__debug_halt` is emitted at the very end of // bytes, and `__debug_halt` is emitted at the very end of

View file

@ -42,6 +42,7 @@ pub enum ErrorCode {
W0105, // palette sub-palette universal mismatch (mirror collision) W0105, // palette sub-palette universal mismatch (mirror collision)
W0106, // implicit drop of non-void function return value W0106, // implicit drop of non-void function return value
W0107, // `fast` variable rarely accessed (wastes zero-page slot) W0107, // `fast` variable rarely accessed (wastes zero-page slot)
W0108, // array elements past byte 255 unreachable via 8-bit X index
} }
impl fmt::Display for ErrorCode { impl fmt::Display for ErrorCode {
@ -68,6 +69,7 @@ impl fmt::Display for ErrorCode {
Self::W0105 => "W0105", Self::W0105 => "W0105",
Self::W0106 => "W0106", Self::W0106 => "W0106",
Self::W0107 => "W0107", Self::W0107 => "W0107",
Self::W0108 => "W0108",
}; };
write!(f, "{code}") write!(f, "{code}")
} }
@ -82,7 +84,8 @@ impl ErrorCode {
| Self::W0104 | Self::W0104
| Self::W0105 | Self::W0105
| Self::W0106 | Self::W0106
| Self::W0107 => Level::Warning, | Self::W0107
| Self::W0108 => Level::Warning,
_ => Level::Error, _ => Level::Error,
} }
} }

View file

@ -1455,11 +1455,28 @@ pub fn gen_bank_select(mapper: Mapper) -> Vec<Instruction> {
out.push(Instruction::implied(RTS)); out.push(Instruction::implied(RTS));
} }
Mapper::UxROM => { Mapper::UxROM => {
// UxROM: write bank number to any address in $8000-$FFFF. // UxROM: write the bank number to any address in
// We use $FFF0 so the write lands in the fixed bank's // $8000-$FFFF. On boards with bus conflicts the CPU's
// tail area where the linker can back it with a matching // write and the ROM byte at that address are ANDed on
// bank-table byte to avoid bus conflicts. // the data bus, so we must write to an address whose
out.push(Instruction::new(STA, AM::Absolute(0xFFF0))); // ROM byte already equals the bank number. The linker
// splices a 256-byte table (`__bank_select_table`,
// bytes 0..255) into the fixed bank, and we index into
// it with X = bank number: `STA __bank_select_table, X`
// stores A (= bank number) at
// `__bank_select_table + X`, whose ROM byte is exactly
// X, so bus = A = X = ROM — no conflict.
//
// Previously this wrote to a fixed `$FFF0`, which
// happens to work on emulators that don't simulate bus
// conflicts (jsnes, Mesen permissive) but would glitch
// on real hardware because a single ROM byte can't
// match every possible bank number.
out.push(Instruction::implied(TAX));
out.push(Instruction::new(
STA,
AM::LabelAbsoluteX("__bank_select_table".into()),
));
out.push(Instruction::implied(RTS)); out.push(Instruction::implied(RTS));
} }
Mapper::MMC3 => { Mapper::MMC3 => {

View file

@ -674,14 +674,32 @@ fn bank_select_mmc1_serializes_five_bits_to_e000() {
} }
#[test] #[test]
fn bank_select_uxrom_writes_fff0() { fn bank_select_uxrom_uses_bus_conflict_table() {
// UxROM bank-select writes A to $FFF0, which lives in the // UxROM bank-select has to write to a ROM address whose byte
// fixed bank's bus-conflict-safe table. // already equals the bank being selected. The routine does
// `TAX; STA __bank_select_table, X` so the store goes to
// `table + bank_num`, whose ROM byte is `bank_num` — making
// the bus write match the ROM byte regardless of which bank
// is being requested. We assert both pieces here so a
// regression back to the broken `STA $FFF0` form fails
// loudly.
let sel = gen_bank_select(Mapper::UxROM); let sel = gen_bank_select(Mapper::UxROM);
let has_write = sel let has_tax = sel.iter().any(|i| i.opcode == TAX && i.mode == AM::Implied);
assert!(has_tax, "UxROM bank-select must TAX before the store");
let has_indexed_store = sel.iter().any(|i| {
i.opcode == STA && matches!(&i.mode, AM::LabelAbsoluteX(n) if n == "__bank_select_table")
});
assert!(
has_indexed_store,
"UxROM bank-select must `STA __bank_select_table,X`"
);
let writes_fff0 = sel
.iter() .iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0xFFF0)); .any(|i| i.opcode == STA && i.mode == AM::Absolute(0xFFF0));
assert!(has_write, "UxROM bank-select must write to $FFF0"); assert!(
!writes_fff0,
"UxROM bank-select must not fall back to the bus-conflict-unsafe STA $FFF0 form"
);
assert_eq!(sel.last().unwrap().opcode, RTS); assert_eq!(sel.last().unwrap().opcode, RTS);
} }
@ -723,7 +741,15 @@ fn bank_select_stashes_bank_number_in_zp() {
#[test] #[test]
fn bank_select_assembles_for_every_mapper() { fn bank_select_assembles_for_every_mapper() {
for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] { for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
let sel = gen_bank_select(m); let mut sel = gen_bank_select(m);
// UxROM `gen_bank_select` references `__bank_select_table`
// via `AM::LabelAbsoluteX`; give the assembler something
// to resolve against in this standalone unit test. Real
// linking appends the table in the linker pass, so the
// label always resolves there.
if m == Mapper::UxROM {
sel.extend(super::gen_uxrom_bank_table());
}
let result = asm::assemble(&sel, 0xC000); let result = asm::assemble(&sel, 0xC000);
assert!( assert!(
!result.bytes.is_empty(), !result.bytes.is_empty(),

View file

@ -2260,6 +2260,155 @@ start Main
); );
} }
#[test]
fn source_map_survives_aggressive_peephole_folding() {
// Regression guard for the concern raised in code review:
// `__src_<N>` markers are emitted as label pseudo-ops, and
// peephole uses labels as block boundaries. If peephole ever
// started pruning unreferenced labels the source map would
// silently lose entries. Compile a program that trips the
// peephole store-then-load and redundant-load folds on every
// single line, then assert every `__src_` label the codegen
// recorded is still in the linker's label table post-peephole.
let source = r#"
game "PeepholeFolds" { mapper: NROM }
var t0: u8 = 0
var t1: u8 = 0
var t2: u8 = 0
var t3: u8 = 0
var t4: u8 = 0
on frame {
t0 = 1
t1 = t0
t2 = t1
t3 = t2
t4 = t3
t0 = t4
wait_frame
}
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 palettes = assets::resolve_palettes(&program, Path::new(".")).unwrap();
let backgrounds = assets::resolve_backgrounds(&program, Path::new(".")).unwrap();
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
.with_sprites(&sprites)
.with_audio(&sfx, &music)
.with_source_map(true);
let mut instructions = codegen.generate(&ir_program);
// Snapshot the __src_ labels the codegen recorded BEFORE
// peephole runs.
let pre_peephole: std::collections::HashSet<String> = codegen
.source_locs()
.iter()
.map(|(name, _)| name.clone())
.collect();
assert!(
pre_peephole.len() >= 6,
"codegen should have recorded at least one source loc per statement, got {} from {pre_peephole:?}",
pre_peephole.len()
);
// Run peephole. This is the pass that the reviewer worried
// might drop labels.
nescript::codegen::peephole::optimize(&mut instructions);
// Link and inspect the resolved label table.
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
let switchable_banks: Vec<PrgBank> = program
.banks
.iter()
.filter(|b| b.bank_type == BankType::Prg)
.map(|b| PrgBank::empty(&b.name))
.collect();
let link_result = linker.link_banked_with_ppu_detailed(
&instructions,
&sprites,
&sfx,
&music,
&palettes,
&backgrounds,
&switchable_banks,
);
// Every pre-peephole __src_ label must survive into the final
// linker label table. If peephole ever deletes a label this
// loop fails with the exact label that vanished.
for name in &pre_peephole {
assert!(
link_result.labels.contains_key(name),
"peephole dropped source marker {name}; this breaks source maps"
);
}
}
#[test]
fn debug_frame_overrun_counter_reads_back_from_user_code() {
// End-to-end contract test for the frame-overrun counter:
// when compiled with `--debug`, the NMI handler increments
// `$07FF` whenever the main loop didn't reach `wait_frame`
// in time, and user code is expected to read that counter
// with `peek(0x07FF)`. This test verifies three things that
// together make the feature usable:
//
// 1. The NMI handler's INC $07FF is still present.
// 2. A user `peek(0x07FF)` lowers to a matching LDA $07FF.
// 3. The analyzer's RAM allocator doesn't hand out $07FF
// to a user variable, so the peek reads the counter
// and not some unrelated byte.
let source = r#"
game "Overrun" { mapper: NROM }
var last_overruns: u8 = 0
on frame {
last_overruns = peek(0x07FF)
wait_frame
}
start Main
"#;
let (rom, _mlb, _map) = compile_with_debug_artifacts(source, true);
let prg = &rom[16..16 + 16384];
// (1) NMI bumps the counter — look for `INC $07FF`
// (opcode EE, lo FF, hi 07).
let inc_07ff: [u8; 3] = [0xEE, 0xFF, 0x07];
assert!(
prg.windows(inc_07ff.len()).any(|w| w == inc_07ff),
"debug NMI handler should INC $07FF"
);
// (2) User peek lowers to an `LDA $07FF` somewhere in the
// frame handler (opcode AD, lo FF, hi 07).
let lda_07ff: [u8; 3] = [0xAD, 0xFF, 0x07];
assert!(
prg.windows(lda_07ff.len()).any(|w| w == lda_07ff),
"user `peek(0x07FF)` should lower to LDA $07FF"
);
// (3) No user variable should be allocated at $07FF — verify
// by re-parsing + re-analyzing and walking the allocations.
let (program, _) = nescript::parser::parse(source);
let program = program.unwrap();
let analysis = analyzer::analyze(&program);
assert!(
analysis.var_allocations.iter().all(|a| {
// Last allocated byte is address + size - 1.
let last = a.address + a.size - 1;
last < 0x07FF
}),
"user variable must not land on the debug overrun counter at $07FF: {:?}",
analysis.var_allocations
);
}
#[test] #[test]
fn debug_build_emits_bounds_check_halt_routine() { fn debug_build_emits_bounds_check_halt_routine() {
// When compiled with `--debug`, a program that indexes an // When compiled with `--debug`, a program that indexes an