mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
Bug B: runtime OAM cursor so draw inside loops actually works
`IrCodeGen::next_oam_slot` incremented at *compile time*: one
`draw` statement = one fixed OAM slot, baked into absolute-mode
stores at codegen. A `draw` inside a `while`/`for`/`loop` body
was lowered once and then always wrote to the same four OAM
bytes every iteration, so only the last iteration was ever
visible. The writeup in the earlier PR called this "bug B".
Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the
top of every frame handler (right after the existing OAM clear
loop), and lower each `DrawSprite` IR op to:
LDY $09 ; load cursor
LDA <y_temp>
STA $0200,Y ; sprite Y
LDA #tile
STA $0201,Y ; tile
LDA #0
STA $0202,Y ; attr
LDA <x_temp>
STA $0203,Y ; sprite X
INC $09 x4 ; bump cursor by 4
Cost is ~+6 bytes per `draw` over the old static form. At 64
slots the u8 cursor wraps naturally, giving classic NES
"too many sprites" flicker instead of a silent compile-time
drop. `next_oam_slot` and its resets are gone from the IR
codegen entirely.
Secondary fix: `for i in 0..N` counters are now registered as
handler locals. `lower_statement` created a `VarId` for the
counter via `get_or_create_var` but never pushed it onto
`current_locals`, so the IR codegen's `var_addrs` lookup
returned `None` for every `StoreVar(i)` / `LoadVar(i)` and
silently emitted nothing. The counter stayed at 0 forever,
the loop spun indefinitely, and every iteration wrote the
first array element into OAM — turning all 64 sprites into
the same smiley. Same class as the handler-local `var` decl
bug from the earlier PR, just for for-loop variables.
Smoke-test deltas (all 14/14 still pass):
- arrays_and_functions: 104 -> 260 (player + 4 enemies)
- bitwise_ops: 104 -> 416 (player + flag sprites + pips)
- loop_break_continue: 208 -> 208 (already fixed by the earlier pass)
- structs_enums_for: 104 -> 260 (player + 4 enemies)
Regression tests:
- `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a
single `draw` emits `LDY cursor`, four `STA $020N,Y`, and
four `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots`
— rewritten for the new form: each draw gets its own
`LDY cursor` + 4 `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` —
proves a `draw` inside a `while` compiles to ONE cursor-based
draw (not N unrolled statics and not zero), and asserts no
stray `STA $0204`/`$0208`/... absolute stores — those would
indicate bug B has regressed.
- `ir::tests::for_loop_counter_is_registered_as_handler_local`
— verifies `for i in 0..N` pushes `i` onto `current_locals`
so the IR codegen allocates it.
Smoke-test tightening: `tests/emulator/run_examples.mjs` now
has per-example `minNonBlack` floors. `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and `bitwise_ops`
all require multi-sprite rendering — if the OAM cursor bug
comes back, the smoke test fails loudly instead of passing on
the default `nonBlack > 0` check.
The legacy AST codegen in `src/codegen/mod.rs` still uses the
compile-time `next_oam_slot` approach. It's only reachable via
`--use-ast`, none of the examples use it, and its integration
tests only check iNES structure — left alone on purpose.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
This commit is contained in:
parent
f49dbce686
commit
54acb9ee38
8 changed files with 274 additions and 39 deletions
|
|
@ -14,7 +14,8 @@
|
|||
//! - `$03` `current_state`
|
||||
//! - `$04-$07` function call params
|
||||
//! - `$08` input P2
|
||||
//! - `$09-$0F` reserved
|
||||
//! - `$09` runtime OAM cursor (used by `draw` to assign slots)
|
||||
//! - `$0A-$0F` reserved
|
||||
//! - `$10+` user variables + IR temp slots
|
||||
//!
|
||||
//! IR temps are allocated starting at `TEMP_BASE` (`$80`), giving 128 bytes
|
||||
|
|
@ -26,6 +27,7 @@ use std::collections::HashMap;
|
|||
use crate::analyzer::VarAllocation;
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId};
|
||||
use crate::runtime::ZP_OAM_CURSOR;
|
||||
|
||||
/// Base zero-page address for IR temp slots.
|
||||
const TEMP_BASE: u8 = 0x80;
|
||||
|
|
@ -54,8 +56,6 @@ pub struct IrCodeGen<'a> {
|
|||
state_indices: HashMap<String, u8>,
|
||||
/// Set of function names defined in the IR program (for existence checks).
|
||||
function_names: std::collections::HashSet<String>,
|
||||
/// Next OAM slot to allocate (0-63); reset at start of each frame handler.
|
||||
next_oam_slot: u8,
|
||||
/// True while generating code inside a state frame handler.
|
||||
/// When set, `Return` terminators emit `JMP __ir_main_loop` instead of `RTS`.
|
||||
in_frame_handler: bool,
|
||||
|
|
@ -122,7 +122,6 @@ impl<'a> IrCodeGen<'a> {
|
|||
sprite_tiles: HashMap::new(),
|
||||
state_indices: HashMap::new(),
|
||||
function_names,
|
||||
next_oam_slot: 0,
|
||||
in_frame_handler: false,
|
||||
debug_mode: false,
|
||||
allocations,
|
||||
|
|
@ -372,14 +371,10 @@ impl<'a> IrCodeGen<'a> {
|
|||
}
|
||||
|
||||
fn gen_function(&mut self, func: &IrFunction) {
|
||||
// Reset temp slot allocator per function
|
||||
// Reset temp slot allocator per function.
|
||||
self.temp_slots.clear();
|
||||
self.next_temp_slot = 0;
|
||||
// Reset OAM slot counter per frame handler
|
||||
self.in_frame_handler = func.name.ends_with("_frame");
|
||||
if self.in_frame_handler {
|
||||
self.next_oam_slot = 0;
|
||||
}
|
||||
|
||||
self.emit_label(&format!("__ir_fn_{}", func.name));
|
||||
|
||||
|
|
@ -402,6 +397,14 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(INX, AM::Implied);
|
||||
self.emit(INX, AM::Implied);
|
||||
self.emit(BNE, AM::LabelRelative(clear_loop));
|
||||
|
||||
// Reset the runtime OAM cursor so the first `draw`
|
||||
// writes to slot 0. Every subsequent `draw` in this
|
||||
// handler bumps the cursor by 4 — including draws
|
||||
// inside loops, which is why this replaces the old
|
||||
// compile-time `next_oam_slot` bookkeeping.
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
self.emit(STA, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
}
|
||||
|
||||
for block in &func.blocks {
|
||||
|
|
@ -568,20 +571,41 @@ impl<'a> IrCodeGen<'a> {
|
|||
y,
|
||||
frame,
|
||||
} => {
|
||||
// Allocate an OAM slot from $0200-$02FF. Silently drop
|
||||
// draws beyond slot 63 (OAM is full).
|
||||
if self.next_oam_slot >= 64 {
|
||||
return;
|
||||
}
|
||||
let slot = self.next_oam_slot;
|
||||
self.next_oam_slot = self.next_oam_slot.wrapping_add(1);
|
||||
let base = 0x0200 + u16::from(slot) * 4;
|
||||
// Runtime OAM-cursor-based draw. Each frame handler
|
||||
// resets `ZP_OAM_CURSOR` to 0 after the OAM clear; a
|
||||
// `draw` loads the cursor into Y, writes the four
|
||||
// sprite bytes via `STA $0200,Y` / `$0201,Y` / etc.,
|
||||
// then bumps the cursor by 4 so the next `draw`
|
||||
// lands in the next slot.
|
||||
//
|
||||
// This lets `draw` inside a loop body actually
|
||||
// write to a fresh slot on every iteration — with
|
||||
// the old static `next_oam_slot` scheme every
|
||||
// iteration of a loop clobbered the same 4 bytes,
|
||||
// so only the last iteration was visible.
|
||||
//
|
||||
// At 64 slots the cursor naturally wraps (u8
|
||||
// overflow) and subsequent draws overwrite the
|
||||
// oldest slots — the classic NES "too many
|
||||
// sprites" flicker behavior rather than a silent
|
||||
// compile-time drop.
|
||||
//
|
||||
// Cost over the old static scheme is +1 `LDY`, +4
|
||||
// `INC` (cursor bumps), so roughly +6 bytes per
|
||||
// draw. Worth it for correct loop semantics.
|
||||
|
||||
// Y position at base+0
|
||||
// Load the cursor into Y so the four stores below
|
||||
// all index off the current slot. We do this
|
||||
// once per draw — Y isn't preserved across JSRs
|
||||
// or between unrelated ops, so each draw owns Y
|
||||
// for its duration.
|
||||
self.emit(LDY, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
|
||||
// Y position at cursor+0
|
||||
self.load_temp(*y);
|
||||
self.emit(STA, AM::Absolute(base));
|
||||
self.emit(STA, AM::AbsoluteY(0x0200));
|
||||
|
||||
// Tile index at base+1 — frame override, sprite lookup, or 0
|
||||
// Tile index at cursor+1 — frame override, sprite lookup, or 0
|
||||
if let Some(f) = frame {
|
||||
self.load_temp(*f);
|
||||
} else if let Some(&tile) = self.sprite_tiles.get(sprite_name) {
|
||||
|
|
@ -589,15 +613,24 @@ impl<'a> IrCodeGen<'a> {
|
|||
} else {
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
}
|
||||
self.emit(STA, AM::Absolute(base + 1));
|
||||
self.emit(STA, AM::AbsoluteY(0x0201));
|
||||
|
||||
// Attributes at base+2 (always 0)
|
||||
// Attributes at cursor+2 (always 0)
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
self.emit(STA, AM::Absolute(base + 2));
|
||||
self.emit(STA, AM::AbsoluteY(0x0202));
|
||||
|
||||
// X position at base+3
|
||||
// X position at cursor+3
|
||||
self.load_temp(*x);
|
||||
self.emit(STA, AM::Absolute(base + 3));
|
||||
self.emit(STA, AM::AbsoluteY(0x0203));
|
||||
|
||||
// Advance the cursor by 4. INC $zp is 2 cycles
|
||||
// and 2 bytes — four of them are smaller and
|
||||
// faster than LDA/CLC/ADC #4/STA. u8 overflow at
|
||||
// slot 64 wraps naturally.
|
||||
self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
self.emit(INC, AM::ZeroPage(ZP_OAM_CURSOR));
|
||||
}
|
||||
IrOp::ReadInput(dest, player) => {
|
||||
// $01 = P1 input byte, $08 = P2 input byte
|
||||
|
|
@ -916,9 +949,21 @@ mod tests {
|
|||
start Main
|
||||
"#,
|
||||
);
|
||||
// Should write to OAM slot 0
|
||||
assert!(has_instruction(&insts, STA, &AM::Absolute(0x0200)));
|
||||
assert!(has_instruction(&insts, STA, &AM::Absolute(0x0203)));
|
||||
// The runtime OAM cursor approach writes the four bytes
|
||||
// of each sprite via `STA $0200,Y` through `STA $0203,Y`
|
||||
// with `Y` loaded from the `ZP_OAM_CURSOR` zero-page
|
||||
// slot. Verify the full shape of a draw: the cursor
|
||||
// load, the four indexed stores, and the cursor bump.
|
||||
assert!(has_instruction(&insts, LDY, &AM::ZeroPage(0x09)));
|
||||
assert!(has_instruction(&insts, STA, &AM::AbsoluteY(0x0200)));
|
||||
assert!(has_instruction(&insts, STA, &AM::AbsoluteY(0x0201)));
|
||||
assert!(has_instruction(&insts, STA, &AM::AbsoluteY(0x0202)));
|
||||
assert!(has_instruction(&insts, STA, &AM::AbsoluteY(0x0203)));
|
||||
let cursor_bumps = insts
|
||||
.iter()
|
||||
.filter(|i| i.opcode == INC && i.mode == AM::ZeroPage(0x09))
|
||||
.count();
|
||||
assert_eq!(cursor_bumps, 4, "draw should bump cursor by 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1031,15 +1076,23 @@ mod more_tests {
|
|||
start Main
|
||||
"#,
|
||||
);
|
||||
// Should write to OAM slot 0 ($0200) and slot 1 ($0204)
|
||||
let writes_slot0 = insts
|
||||
// With the runtime OAM cursor, sequential slots come for
|
||||
// free at runtime: each `draw` bumps `ZP_OAM_CURSOR` by 4
|
||||
// so the next draw's `STA $0200,Y` lands 4 bytes later.
|
||||
// We can't check slot numbers statically any more, but
|
||||
// we can check that (a) both draws emitted their cursor
|
||||
// loads, and (b) the total cursor-bump count matches the
|
||||
// number of draws.
|
||||
let lda_cursor = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x0200));
|
||||
let writes_slot1 = insts
|
||||
.filter(|i| i.opcode == LDY && i.mode == AM::ZeroPage(0x09))
|
||||
.count();
|
||||
let cursor_bumps = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x0204));
|
||||
assert!(writes_slot0, "first draw should use OAM slot 0");
|
||||
assert!(writes_slot1, "second draw should use OAM slot 1");
|
||||
.filter(|i| i.opcode == INC && i.mode == AM::ZeroPage(0x09))
|
||||
.count();
|
||||
assert_eq!(lda_cursor, 2, "each draw should LDY cursor once");
|
||||
assert_eq!(cursor_bumps, 8, "each draw should bump cursor 4 times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1205,4 +1258,78 @@ mod more_tests {
|
|||
let has_brk = insts.iter().any(|i| i.opcode == BRK);
|
||||
assert!(has_brk, "debug.assert should emit BRK on failure path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ir_codegen_draw_in_loop_emits_one_cursor_based_draw_not_unrolled() {
|
||||
// Regression test for bug B. A `draw` inside a `while`
|
||||
// loop body must compile to ONE cursor-based draw that
|
||||
// runs on every iteration — not zero draws (original
|
||||
// bug when combined with handler-local VarDecl tracking)
|
||||
// and not unrolled-per-slot static stores (the old bug
|
||||
// where `next_oam_slot` was incremented at compile time,
|
||||
// so only the last iteration was ever visible).
|
||||
//
|
||||
// Concretely: we should see exactly one `LDY $09` and
|
||||
// four `INC $09` — the shape of a single draw — inside
|
||||
// the loop body, and NO static `STA $0200` / `$0204` /
|
||||
// `$0208` / `$020C` patterns (which would indicate the
|
||||
// old compile-time slot bump).
|
||||
let insts = lower_and_gen(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var xs: u8[4] = [10, 40, 80, 120]
|
||||
on frame {
|
||||
var i: u8 = 0
|
||||
while i < 4 {
|
||||
draw Smiley at: (xs[i], xs[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
|
||||
let cursor_loads = insts
|
||||
.iter()
|
||||
.filter(|i| i.opcode == LDY && i.mode == AM::ZeroPage(0x09))
|
||||
.count();
|
||||
let cursor_bumps = insts
|
||||
.iter()
|
||||
.filter(|i| i.opcode == INC && i.mode == AM::ZeroPage(0x09))
|
||||
.count();
|
||||
assert_eq!(
|
||||
cursor_loads, 1,
|
||||
"a single `draw` in a loop should emit one `LDY cursor` (body is emitted once)"
|
||||
);
|
||||
assert_eq!(
|
||||
cursor_bumps, 4,
|
||||
"a single `draw` in a loop should emit 4 `INC cursor`"
|
||||
);
|
||||
|
||||
// There must be AT LEAST ONE `STA $0200,Y` (the Y-byte
|
||||
// store); other slot-0-absolute stores are a smell but
|
||||
// allowed for non-draw code.
|
||||
let has_abs_y_store = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::AbsoluteY(0x0200));
|
||||
assert!(
|
||||
has_abs_y_store,
|
||||
"draw should emit `STA $0200,Y` (runtime-cursor indexed store)"
|
||||
);
|
||||
|
||||
// No `STA $0204` / `$0208` / `$020C` — those would
|
||||
// indicate the compiler allocated four separate static
|
||||
// OAM slots for a single draw statement (bug B).
|
||||
for slot in 1..16u16 {
|
||||
let addr = 0x0200 + slot * 4;
|
||||
let has_stale_static = insts
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(addr));
|
||||
assert!(
|
||||
!has_stale_static,
|
||||
"unexpected static OAM slot {slot} store at ${addr:04X} \
|
||||
— bug B regression (compile-time slot bumps are back)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,6 +401,22 @@ impl LoweringContext {
|
|||
// var = start
|
||||
// while var < end { body; var = var + 1 }
|
||||
let var_id = self.get_or_create_var(var);
|
||||
// The loop variable is implicitly declared by the
|
||||
// `for` statement — track it as a local so the IR
|
||||
// codegen allocates backing storage. Without this
|
||||
// the `StoreVar`/`LoadVar` ops for the counter are
|
||||
// silently dropped by `IrCodeGen` (`var_addrs`
|
||||
// has no entry), making the counter permanently 0
|
||||
// and turning the loop into an infinite one. Same
|
||||
// class of bug as handler-local `var` decls before
|
||||
// the earlier fix.
|
||||
if !self.current_locals.iter().any(|l| l.var_id == var_id) {
|
||||
self.current_locals.push(IrLocal {
|
||||
var_id,
|
||||
name: var.clone(),
|
||||
size: 1,
|
||||
});
|
||||
}
|
||||
let start_temp = self.lower_expr(start);
|
||||
self.emit(IrOp::StoreVar(var_id, start_temp));
|
||||
// Precompute the end value once outside the loop
|
||||
|
|
|
|||
|
|
@ -341,3 +341,39 @@ fn array_literal_global_init_is_captured() {
|
|||
xs.init_array
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_loop_counter_is_registered_as_handler_local() {
|
||||
// Regression test for bug B's secondary fix: `for i in 0..N`
|
||||
// implicitly declares the counter `i`, and the lowering must
|
||||
// push it onto `current_locals` so the IR codegen can give
|
||||
// it a backing address. Without this entry, every
|
||||
// `LoadVar(i)` / `StoreVar(i)` in the desugared while loop
|
||||
// silently emitted no code (the codegen's `var_addrs` lookup
|
||||
// returned None), the counter stayed at 0, the loop spun
|
||||
// forever, and any `draw` inside the loop kept writing to
|
||||
// the first OAM slot with the index-0 array element.
|
||||
let ir = lower_ok(
|
||||
r#"
|
||||
game "ForCounter" { mapper: NROM }
|
||||
var xs: u8[4] = [1, 2, 3, 4]
|
||||
var out: u8 = 0
|
||||
on frame {
|
||||
for i in 0..4 {
|
||||
out = xs[i]
|
||||
}
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let frame_fn = ir
|
||||
.functions
|
||||
.iter()
|
||||
.find(|f| f.name.contains("frame"))
|
||||
.expect("frame handler should exist");
|
||||
assert!(
|
||||
frame_fn.locals.iter().any(|l| l.name == "i"),
|
||||
"for-loop counter `i` should be registered as a handler local: {:?}",
|
||||
frame_fn.locals
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ const APU_FRAME: u16 = 0x4017;
|
|||
pub const ZP_FRAME_FLAG: u8 = 0x00;
|
||||
pub const ZP_INPUT_P1: u8 = 0x01;
|
||||
pub const ZP_INPUT_P2: u8 = 0x08;
|
||||
/// Runtime OAM cursor, incremented by 4 on every `draw` inside a
|
||||
/// frame handler. The IR codegen resets this to 0 after the OAM
|
||||
/// clear at the top of the handler, so each `draw` writes to the
|
||||
/// next 4-byte sprite slot regardless of how many loop iterations
|
||||
/// came before it. At 64 slots the u8 naturally wraps to 0 and
|
||||
/// the oldest slot gets overwritten — the classic NES flicker
|
||||
/// fallback.
|
||||
pub const ZP_OAM_CURSOR: u8 = 0x09;
|
||||
|
||||
/// Generate the NES hardware initialization sequence.
|
||||
/// This runs at RESET and sets up the hardware before user code.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@ const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString(
|
|||
const FRAMES_TO_RUN = 180; // ~3 seconds at 60 fps, enough to get past a title/boot
|
||||
const SCREENSHOT_FRAME = 180;
|
||||
|
||||
// Per-example non-black pixel floors, used to catch silent
|
||||
// render regressions. A bare smiley sprite contributes ~52
|
||||
// non-black pixels; the default floor below assumes one visible
|
||||
// sprite. Examples that draw more sprites override the floor
|
||||
// with a tighter value so bugs like "only one of the four
|
||||
// enemies actually shows up" fail CI instead of silently
|
||||
// slipping past the base `nonBlack > 0` check.
|
||||
//
|
||||
// Each entry is `[minNonBlack, note]`. The note is printed when
|
||||
// the floor fails so it's easy to tell what the example was
|
||||
// supposed to show.
|
||||
const DEFAULT_MIN_NON_BLACK = 40; // one small sprite, conservative
|
||||
const EXAMPLE_FLOORS = {
|
||||
arrays_and_functions: [200, "player + 4 enemies drawn by a while loop"],
|
||||
bitwise_ops: [150, "player + multiple flag/pip sprites across if branches and a while loop"],
|
||||
loop_break_continue: [150, "player + 3 active hazards (one slot is inactive)"],
|
||||
structs_enums_for: [200, "player + 4 enemies drawn by a `for` loop"],
|
||||
sprites_and_palettes: [60, "custom CHR tiles visible"],
|
||||
scanline_split: [80, "banner + player"],
|
||||
};
|
||||
|
||||
async function listRoms() {
|
||||
const entries = await fs.readdir(examplesDir);
|
||||
return entries
|
||||
|
|
@ -25,6 +46,12 @@ async function listRoms() {
|
|||
.map((f) => ({ name: f.replace(/\.nes$/, ""), file: path.join(examplesDir, f) }));
|
||||
}
|
||||
|
||||
function floorFor(name) {
|
||||
const entry = EXAMPLE_FLOORS[name];
|
||||
if (entry) return entry;
|
||||
return [DEFAULT_MIN_NON_BLACK, "generic single-sprite floor"];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(screenshotsDir, { recursive: true });
|
||||
const roms = await listRoms();
|
||||
|
|
@ -90,13 +117,25 @@ async function main() {
|
|||
}
|
||||
|
||||
const lastStats = frameHashes[frameHashes.length - 1];
|
||||
const firstStats = frameHashes[0];
|
||||
const uniqueHashes = new Set(frameHashes.map((h) => h.hash)).size;
|
||||
const rendered = booted && lastStats && lastStats.nonBlack > 0;
|
||||
const animated = uniqueHashes > 1;
|
||||
|
||||
const status = rendered ? "OK" : "FAIL";
|
||||
if (!rendered) failures++;
|
||||
const [minNonBlack, floorNote] = floorFor(rom.name);
|
||||
const meetsFloor = rendered && lastStats.nonBlack >= minNonBlack;
|
||||
const pass = rendered && meetsFloor;
|
||||
|
||||
const status = pass ? "OK" : "FAIL";
|
||||
if (!pass) failures++;
|
||||
|
||||
let failReason = null;
|
||||
if (!booted) {
|
||||
failReason = `boot error: ${bootError ?? "unknown"}`;
|
||||
} else if (!rendered) {
|
||||
failReason = "rendered a fully black screen (nonBlack=0)";
|
||||
} else if (!meetsFloor) {
|
||||
failReason = `nonBlack=${lastStats.nonBlack} below floor=${minNonBlack} (${floorNote})`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: rom.name,
|
||||
|
|
@ -104,6 +143,10 @@ async function main() {
|
|||
bootError,
|
||||
rendered,
|
||||
animated,
|
||||
meetsFloor,
|
||||
minNonBlack,
|
||||
floorNote,
|
||||
failReason,
|
||||
frames: frameHashes,
|
||||
consoleErrors,
|
||||
screenshot: booted ? path.relative(repoRoot, screenshotPath) : null,
|
||||
|
|
@ -112,9 +155,14 @@ async function main() {
|
|||
console.log(
|
||||
`${status.padEnd(4)} ${rom.name.padEnd(28)} ` +
|
||||
(rendered
|
||||
? `nonBlack=${lastStats.nonBlack}/${lastStats.totalPixels} uniqueHashes=${uniqueHashes} animated=${animated}`
|
||||
? `nonBlack=${lastStats.nonBlack}/${lastStats.totalPixels} (floor=${minNonBlack}) uniqueHashes=${uniqueHashes} animated=${animated}`
|
||||
: `boot=${booted} bootError=${bootError ?? "none"}`),
|
||||
);
|
||||
if (failReason && !rendered) {
|
||||
console.log(` reason: ${failReason}`);
|
||||
} else if (failReason) {
|
||||
console.log(` reason: ${failReason}`);
|
||||
}
|
||||
if (consoleErrors.length > 0) {
|
||||
for (const e of consoleErrors) console.log(" console:", e);
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 998 B |
Binary file not shown.
|
Before Width: | Height: | Size: 884 B After Width: | Height: | Size: 908 B |
Binary file not shown.
|
Before Width: | Height: | Size: 854 B After Width: | Height: | Size: 911 B |
Loading…
Add table
Add a link
Reference in a new issue