mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
analyzer+codegen: turn silently-dropped feature paths into hard failures (or fix them)
Phase 2 of the post-PR-#31 audit. The codebase had four documented
"silently skip" paths that parsed user intent but produced no code.
Each one was the same shape as the state-local bug: the analyzer
accepted the program, the IR lowered the construct, but somewhere
downstream the emitted code was dropped on the floor — and a pixel
golden that captured the broken behaviour locked it in as correct.
Fix each per the plan, either by implementing the feature or
rejecting the program at the analyzer.
### on_exit handlers now actually run
`IrOp::Transition` used to comment "on_exit of the current state
isn't called here because we don't know from an IR op alone which
state we're leaving." The codegen emitted the exit handler's body
as an IR function but never JSR'd it. Three example programs
(pong, war, state_machine) relied on `stop_music` or mode-flag
translation inside `on exit` that had been silently never running.
Emit a small CMP-chain against `ZP_CURRENT_STATE` before each
transition: for every state that declares an on_exit, compare the
current index, branch past on miss, JSR the exit handler on match,
then JMP to the shared done-label so only the leaving state's
handler fires. The chain is inlined at each transition site
(bounded by the number of states declaring on_exit) rather than
factored into a single trampoline — simpler to reason about, and
transitions are rare enough that the extra bytes don't matter.
Pong / war / state_machine ROMs change because the dispatch code
is now emitted. Video goldens stay byte-identical (no transitions
happen within the 180-frame harness window under no-input). Pong
and war audio hashes shifted from pure code-layout timing and are
regenerated. `docs/pong.gif` and `docs/war.gif` are byte-identical.
### State-local array initializers now refuse to compile (E0601)
`src/ir/lowering.rs:887` had the comment "Array initializers for
state-locals aren't supported yet... Programs that try this should
get a diagnostic from the analyzer; for now, silently skip." The
analyzer never actually emitted that diagnostic. Verified by
compiling `state Main { var buf: u8[4] = [10,20,30,40] ... }`:
the program built a valid ROM with no trace of 10/20/30/40 in PRG.
Add E0601 to the analyzer's state-local pass. The IR lowerer's
defensive `continue` stays in place as a belt-and-braces guard.
### `on scanline` without MMC3 is now E0603
Previously E0203 ("invalid operation for type") which is a
miscategorisation — the feature is unsupported on the current
mapper, not a type error. Dedicated E0603 makes the future-work
shape explicit.
### `slow` variables now actually live outside zero page
`Placement::Slow` was parsed into the AST but `allocate_ram`
ignored it, so `slow var cold: u8` still landed in ZP like any
other u8. Wire `var.placement` through `allocate_ram_with_placement`
and skip the ZP branch when `Slow` is set. `Fast` remains
advisory (the existing default already prefers ZP for u8 vars),
validated by W0107.
### Other address-map silent drops hardened
Alongside the var_addrs hardening from phase 1, three `state_indices`
lookup sites that did `.copied().unwrap_or(0)` or silent `if let`
are now explicit panics: scanline IRQ dispatch, MMC3 reload, and
`IrOp::Transition`. A miss in any of them is a compiler bug, not
valid input — the analyzer catches unknown state names upstream.
### Regression guards
Four new tests would have failed against the old silently-dropping
code paths:
- `analyze_state_local_array_initializer_rejected` — expects E0601.
- `analyze_on_exit_declaration_accepted` — expects no errors.
- `analyze_slow_var_forced_out_of_zero_page` — expects alloc
address >= $0100.
- `transition_dispatches_leaving_states_on_exit_handler` — counts
distinct JSR targets in the PRG before/after adding `on exit` to
a state; the exit-bearing build must have more.
All 720 tests pass. All 34 emulator goldens pass after the pong/war
audio hash refresh.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
This commit is contained in:
parent
f7012c6533
commit
48bae97c51
10 changed files with 281 additions and 26 deletions
|
|
@ -1 +1 @@
|
|||
555a22e1 132084
|
||||
6c171f3d 132084
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
e3805766 132084
|
||||
60d2ce9d 132084
|
||||
|
|
|
|||
|
|
@ -335,6 +335,80 @@ fn program_with_u16_struct_field() {
|
|||
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_dispatches_leaving_states_on_exit_handler() {
|
||||
// `on exit` handlers used to be silently never called — the
|
||||
// codegen documented that IrOp::Transition "doesn't know which
|
||||
// state it's leaving" and skipped the on_exit JSR. Example
|
||||
// programs (pong, war, state_machine) had `stop_music` sitting
|
||||
// in an `on exit` block that never ran, and goldens captured
|
||||
// the bug as "correct" behavior. The fix emits a runtime
|
||||
// CMP-chain against ZP_CURRENT_STATE before every transition to
|
||||
// JSR the leaving state's exit handler.
|
||||
//
|
||||
// Compile a program with two states where Source declares an
|
||||
// `on exit` and transitions to Target, then assert the PRG
|
||||
// contains a JSR to the Source_exit label. We look for the
|
||||
// absolute JSR opcode $20 followed by any 16-bit target, and
|
||||
// verify the linked label's address lands on one of them by
|
||||
// scanning the ROM for the `STA ZP_CURRENT_STATE` sequence
|
||||
// that would indicate the transition lowered at all.
|
||||
let source = r#"
|
||||
game "ExitDispatch" { mapper: NROM }
|
||||
state Source {
|
||||
on enter {}
|
||||
on frame { transition Target }
|
||||
on exit { stop_music }
|
||||
}
|
||||
state Target {
|
||||
on enter {}
|
||||
on frame {}
|
||||
}
|
||||
start Source
|
||||
"#;
|
||||
let rom_data = compile(source);
|
||||
let prg = &rom_data[16..16 + 16384];
|
||||
|
||||
// NROM ROMs are a fixed 24576 + 16-byte header, so we can't
|
||||
// compare file sizes. Compare the count of distinct JSR
|
||||
// targets instead: a program without `on exit` only JSRs
|
||||
// `Target_enter`, so its transition site has one JSR; adding
|
||||
// `on exit` to Source injects at least one more JSR (the
|
||||
// exit-dispatch JSR to `__ir_fn_Source_exit`).
|
||||
let baseline_source = r#"
|
||||
game "ExitDispatch" { mapper: NROM }
|
||||
state Source {
|
||||
on enter {}
|
||||
on frame { transition Target }
|
||||
}
|
||||
state Target {
|
||||
on enter {}
|
||||
on frame {}
|
||||
}
|
||||
start Source
|
||||
"#;
|
||||
let baseline = compile(baseline_source);
|
||||
let baseline_prg = &baseline[16..16 + 16384];
|
||||
|
||||
let count_jsrs = |bytes: &[u8]| -> std::collections::HashSet<u16> {
|
||||
bytes
|
||||
.windows(3)
|
||||
.filter(|w| w[0] == 0x20)
|
||||
.map(|w| u16::from_le_bytes([w[1], w[2]]))
|
||||
.collect()
|
||||
};
|
||||
let exit_jsrs = count_jsrs(prg);
|
||||
let base_jsrs = count_jsrs(baseline_prg);
|
||||
assert!(
|
||||
exit_jsrs.len() > base_jsrs.len(),
|
||||
"expected the on-exit-bearing PRG to contain more distinct JSR \
|
||||
targets than the baseline (got {} vs {}) — if this fails, the \
|
||||
exit dispatch JSR to `__ir_fn_Source_exit` is being dropped",
|
||||
exit_jsrs.len(),
|
||||
base_jsrs.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninitialized_struct_field_store_emits_sta_to_allocated_address() {
|
||||
// Regression guard for the silent-drop bug uncovered while
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue