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

Add jsnes emulator harness and fix four codegen bugs it surfaced

Running the compiled example ROMs through a headless puppeteer +
local jsnes harness exposed four latent bugs that the
header-structure-only integration tests couldn't catch:

- src/asm/mod.rs: the first pass treated ANY instruction with
  `AddressingMode::Label` as a label definition, silently dropping
  every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label
  def; other opcodes emit the opcode byte plus a 2-byte absolute
  fixup resolved in pass two. Without this, every example crashed
  with "invalid opcode at $1xxx" once the CPU fell through into
  the math runtime and hit an unbalanced `RTS`.

- src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s
  (e.g. `var i: u8 = 0` inside a `while`) were pushed onto
  `current_locals` but the handler built its own throwaway
  `locals` list, so those var ids never got RAM addresses and
  every `LoadVar`/`StoreVar` for them silently emitted nothing.
  Seed `current_locals` with the state's declared locals and
  reuse it so `lower_statement`'s appends flow through to the
  `IrFunction`. Fixes the black screen in `arrays_and_functions`.

- src/ir/lowering.rs (global init): struct-literal initializers
  on globals (`var player: Player = Player { x: 120, ... }`) fell
  through to `eval_const`, which returned `None` for a
  non-literal, so no init code was emitted. Now the per-field
  synthetic globals each get their own `init_value`. Fixes the
  black screen in `structs_enums_for`.

- src/codegen/mod.rs: the legacy AST codegen was emitting
  `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware
  intrinsics. It only "worked" before because the broken
  assembler swallowed the bogus JSR. Handle `poke`/`peek` as
  direct STA/LDA to a compile-time-constant absolute address,
  matching the IR codegen's intrinsic path.

The harness lives in `tests/emulator/`: a tiny HTML page that
wraps the `jsnes` npm package, driven by a puppeteer script that
loads each ROM, runs ~180 frames, snapshots the canvas, and
records a smoke-test verdict (booted without a CPU crash, non-zero
pixels rendered, frames differ over time). `npm install && node
run_examples.mjs` from `tests/emulator/` runs the full sweep.

9/9 example ROMs now load, render, and animate where expected.
All 324 unit + 35 integration tests still pass.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
This commit is contained in:
Claude 2026-04-12 18:46:58 +00:00
parent 593a333eec
commit 81f3fd7de0
No known key found for this signature in database
17 changed files with 1479 additions and 10 deletions

View file

@ -190,6 +190,10 @@ impl LoweringContext {
}
// Lower globals. Initializers can be any constant expression.
// Struct-literal initializers are expanded into per-field
// globals so each field gets its own `init_value`; the parent
// struct itself is still registered (size=0) so any later IR
// op referencing it by name still resolves.
for var in &program.globals {
let var_id = self.get_or_create_var(&var.name);
let init = var.init.as_ref().and_then(|e| self.eval_const(e));
@ -199,6 +203,19 @@ impl LoweringContext {
size: type_size(&var.var_type),
init_value: init,
});
if let Some(Expr::StructLiteral(_, fields, _)) = &var.init {
for (fname, fexpr) in fields {
let full = format!("{}.{fname}", var.name);
let fvid = self.get_or_create_var(&full);
let fval = self.eval_const(fexpr);
self.globals.push(IrGlobal {
var_id: fvid,
name: full,
size: 1,
init_value: fval,
});
}
}
}
// Lower user functions
@ -278,12 +295,20 @@ impl LoweringContext {
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
self.next_temp = 0;
self.current_blocks = Vec::new();
let mut locals = Vec::new();
// Register state-local variables
// Seed `current_locals` with the state's declared locals so any
// `VarDecl` inside the handler body — tracked by
// `lower_statement` via `current_locals` — is appended alongside
// them. Without this, handler-local variables (e.g. a `var i`
// inside a `while`) would get orphaned: their `VarId` would be
// created by `get_or_create_var`, but the `IrFunction`'s
// `locals` list (which the IR codegen uses to allocate RAM
// addresses) would never see them. The result would be a
// silent `LoadVar`/`StoreVar` emit-nothing bug that leaves the
// temp slots uninitialized at runtime.
self.current_locals = Vec::new();
for var in &state.locals {
let var_id = self.get_or_create_var(&var.name);
locals.push(IrLocal {
self.current_locals.push(IrLocal {
var_id,
name: var.name.clone(),
size: type_size(&var.var_type),
@ -298,7 +323,7 @@ impl LoweringContext {
self.functions.push(IrFunction {
name: name.to_string(),
blocks: std::mem::take(&mut self.current_blocks),
locals,
locals: std::mem::take(&mut self.current_locals),
param_count: 0,
has_return: false,
source_span: state.span,