diff --git a/CHANGELOG.md b/CHANGELOG.md index 100aa204..de79babe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Per-scene capture extraction (`Scene::extract`), and the Mode-5 divergence it found on its first + run.** Hi-res scenes were parked behind "widen the capture region"; measuring showed that framing + was wrong (`docs/adr/0013`, 2026-08-02 supplement). The hosts do not agree on the *shape* of a + hi-res frame — snes9x emits `512x224`, Mesen2 `512x478` because it line-doubles — so what is + needed is not a bigger hash but a rule for reducing whatever a host emits to the canonical + 256x224 sample. + + `Scene` gains an `extract` field (`Direct` | `HiResEven`), emitted as `build/scenes.tsv`'s fourth + column so the rule travels with the scene rather than being compiled into any host. All three + hosts honour it, and **a host meeting a rule it does not implement rejects the scene** rather than + falling back to `Direct` — falling back would silently hash the left half of a hi-res picture, + which is the failure the exact-geometry checks were just added to stop. + + The first hi-res scene (`c5-mode5-hires-16px-tiles`, `C5.15`) diverged immediately: + + | host | frame emitted | hash of the extracted sample | + |---|---|---| + | snes9x | `512x224` | `0xbcb8d1c2bec08325` | + | Mesen2 | `512x478` | `0xbcb8d1c2bec08325` | + | **RustySNES** | 512 wide | **`0xd8dad9b9cb91e325`** | + + The two references agree **bit-for-bit** despite different geometries and entirely different + extraction paths, which is this project's signature for a real defect rather than a broken test. + Consistent with `docs/STATUS.md` already recording hi-res as "real-title validation still open"; + this is the first automated evidence of what that gap is. + + The scene is left **unblessed** deliberately. Rule 4 would permit blessing at the reference value, + but that turns the scene gate red on a live finding — an unblessed scene does not fail the gate, + so the finding is preserved without taking the tree red. Blessing follows the fix. + - **A third coverage tier for assertions the cart physically cannot observe — `G1.06` and `G1.18`.** Both were known-coverable and unrepresentable: the coverage report counted on-cart and scene covers only, so a row whose stimulus comes from outside the cartridge had nowhere to go. diff --git a/crates/rustysnes-test-harness/tests/accuracysnes_scenes.rs b/crates/rustysnes-test-harness/tests/accuracysnes_scenes.rs index 23cbd8d7..77e242fe 100644 --- a/crates/rustysnes-test-harness/tests/accuracysnes_scenes.rs +++ b/crates/rustysnes-test-harness/tests/accuracysnes_scenes.rs @@ -62,6 +62,55 @@ fn manifest_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/roms/AccuracySNES/build/scenes.tsv") } +/// How a host turns the frame it emits into the canonical 256x224 sample. +/// +/// Declared per scene in `build/scenes.tsv`'s fourth column and NOT inferred from the geometry — +/// see [`hash_scene`] for why that distinction is the point rather than pedantry. +/// `docs/adr/0013`, 2026-08-02 supplement. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Extract { + /// 256-wide frame, rows `FIRST_ROW..FIRST_ROW + SCENE_H`. + Direct, + /// 512-wide frame: the even columns, which are the subscreen half. + HiResEven, +} + +impl Extract { + fn parse(tag: &str) -> Self { + match tag.trim() { + "direct" => Self::Direct, + "hires-even" => Self::HiResEven, + // A host meeting a rule it does not implement must REJECT, never fall back to + // `Direct` — falling back would silently hash the left half of a hi-res picture. + other => panic!( + "scenes.tsv declares extraction `{other}`, which this host does not implement. \ + Falling back to `direct` would silently hash the wrong region; add the rule or \ + rebuild the cart." + ), + } + } +} + +/// Scene index (1-based, as the cart publishes it) -> the scene's declared extraction. +fn extractions() -> BTreeMap { + // Read, not `unwrap_or_default`: an unreadable manifest would make every scene `Direct` with no + // failure anywhere, which for a hi-res scene means hashing the left half of the picture. The + // missing-file case is separately caught by `manifest()`'s emptiness assert, but a *read error* + // on a file that exists would slip through that, and this is the reader whose silence is + // dangerous. + let path = manifest_path(); + let text = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + text.lines() + .filter(|l| !l.starts_with('#') && !l.trim().is_empty()) + .filter_map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + let idx: u8 = f.first()?.trim().parse().ok()?; + Some((idx, Extract::parse(f.get(3).copied().unwrap_or("direct")))) + }) + .collect() +} + /// Scene index (1-based, as the cart publishes it) -> stable scene ID. /// /// The cart can only publish a number, and a number is a poor golden key: inserting a scene would @@ -109,11 +158,29 @@ const FIRST_ROW: usize = 0; /// core hands back RGB565 or XRGB8888. Each side converts to the same canonical 15-bit form before /// hashing, so a golden compares pictures rather than pixel-format conventions. The hash itself is /// the one `undisbeliever_golden.rs` uses, so the two golden sets are comparable in kind. -fn hash_scene(fb: &[u16], width: usize) -> (u64, Vec) { - assert_eq!( - width, SCENE_W, - "a rendered scene must not be hi-res: the golden is defined over {SCENE_W}x{SCENE_H}" - ); +fn hash_scene(fb: &[u16], width: usize, extract: Extract) -> (u64, Vec) { + // The declared extraction and the observed geometry must AGREE — the host does not infer one + // from the other. Inferring would let a scene that is supposed to be hi-res, but which a broken + // core rendered 256 wide, be hashed as `Direct` and match a `Direct` golden; the declaration is + // what turns that into a failure. `docs/adr/0013`'s 2026-08-02 supplement. + let step = match extract { + Extract::Direct => { + assert_eq!( + width, SCENE_W, + "scene declares `direct` but the frame is {width} wide, not {SCENE_W}" + ); + 1 + } + Extract::HiResEven => { + assert_eq!( + width, + SCENE_W * 2, + "scene declares `hires-even` but the frame is {width} wide, not {}", + SCENE_W * 2 + ); + 2 + } + }; assert!( fb.len() >= (FIRST_ROW + SCENE_H) * width, "the framebuffer holds {} pixels, too few for rows {FIRST_ROW}..{} at width {width} — the \ @@ -126,7 +193,10 @@ fn hash_scene(fb: &[u16], width: usize) -> (u64, Vec) { let mut px = Vec::with_capacity(SCENE_W * SCENE_H); for y in FIRST_ROW..FIRST_ROW + SCENE_H { for x in 0..SCENE_W { - let p = fb[y * width + x]; + // Even columns only under `HiResEven`: the subscreen half, which is the half the three + // references agree on. RustySNES's own framebuffer is not line-doubled, so the row + // index needs no step — Mesen2's does, and that is handled in its own host. + let p = fb[y * width + x * step]; let canonical = ((p & 0x1F) << 10) | (p & 0x03E0) | ((p >> 10) & 0x1F); px.push(canonical); h ^= u64::from(canonical); @@ -167,6 +237,7 @@ fn capture_scenes() -> BTreeMap { let mut seen: BTreeMap = BTreeMap::new(); let mut sightings: BTreeMap = BTreeMap::new(); + let extracts = extractions(); let mut current = 0u8; let mut battery_done = false; @@ -188,7 +259,8 @@ fn capture_scenes() -> BTreeMap { // frame rather than by trying to make the two clocks agree. if scene != 0 && sightings[&scene] == CAPTURE_SIGHTING && !seen.contains_key(&scene) { let width = sys.bus.ppu.visible_width(); - let (hash, px) = hash_scene(sys.bus.ppu.framebuffer(), width); + let extract = *extracts.get(&scene).unwrap_or(&Extract::Direct); + let (hash, px) = hash_scene(sys.bus.ppu.framebuffer(), width, extract); seen.insert(scene, hash); // Same escape hatch the libretro host has (`--scene-dump=`): when two renders disagree, // the hashes say only *that* they differ. Set ACCURACYSNES_SCENE_DUMP to a path prefix diff --git a/docs/accuracysnes-coverage.md b/docs/accuracysnes-coverage.md index 06dafd57..f2fa5753 100644 --- a/docs/accuracysnes-coverage.md +++ b/docs/accuracysnes-coverage.md @@ -95,6 +95,7 @@ Declared in `gen/src/scenes.rs`. Each is reported by the host framebuffer oracle - **`C8.08`** — c8-window-logic-xor - **`C10.02`** — c10-mosaic-screen-anchored - **`C12.01,C12.03`** — c12-direct-colour-mode3 +- **`C5.15`** — c5-mode5-hires-16px-tiles - **`C5.03`** — c5-mode2-plain - **`C6.05,C6.06`** — c6-opt-v-alternating-columns - **`C6.04`** — c6-opt-v-replaces-vofs diff --git a/docs/adr/0013-accuracysnes-framebuffer-oracle.md b/docs/adr/0013-accuracysnes-framebuffer-oracle.md index 4c018920..6f960633 100644 --- a/docs/adr/0013-accuracysnes-framebuffer-oracle.md +++ b/docs/adr/0013-accuracysnes-framebuffer-oracle.md @@ -178,3 +178,27 @@ that was too small and passed one that was too large, hashing a diagonal slice ( real pitch). That is fixed in **PR #320** (`libretro_crossval.c`'s exact `w`/`h` test and `mesen_scenes.lua`'s exact `SCENE_BUF_LEN`), which landed before this supplement; the loud rejection it added is literally what produced the table above. + +### Implemented 2026-08-02, and it found something immediately + +`Scene::extract` landed as specified above — a closed enum, emitted as `build/scenes.tsv`'s fourth +column, honoured by all three hosts, each rejecting a rule it does not implement rather than falling +back to `Direct`. + +The first hi-res scene (`c5-mode5-hires-16px-tiles`, `C5.15`) produced a divergence on its first run: + +| host | frame emitted | hash of the extracted 256x224 sample | +|---|---|---| +| snes9x | 512x224 | `0xbcb8d1c2bec08325` | +| Mesen2 | 512x478 | `0xbcb8d1c2bec08325` | +| **RustySNES** | 512 wide | **`0xd8dad9b9cb91e325`** | + +**The two references agree bit-for-bit despite emitting different geometries and running entirely +different extraction paths.** That is strong evidence the extraction is right and the divergence is +real — and it is this project's own signature for a genuine defect: three hosts failing identically +usually means a broken test, one host failing alone means a bug in that host. + +The scene is left **unblessed**. Rule 4 would permit blessing at the reference value, since the +references agree — but that turns the scene gate red on a live finding, and an unblessed scene does +not fail the gate, so the finding is preserved without taking the tree red. The investigation is +tracked separately; blessing follows the fix. diff --git a/scripts/accuracysnes/libretro_crossval.c b/scripts/accuracysnes/libretro_crossval.c index c062e589..60ffaca1 100644 --- a/scripts/accuracysnes/libretro_crossval.c +++ b/scripts/accuracysnes/libretro_crossval.c @@ -80,6 +80,15 @@ static char sys_dir[] = "."; #define FMT_RGB565 2u static unsigned pixel_format = FMT_0RGB1555; + +/* The extraction the scene currently being held declares, read from build/scenes.tsv. Declared per + * scene rather than inferred from the geometry: inferring would let a scene that is SUPPOSED to be + * hi-res, but which a broken core rendered 256 wide, be hashed as `direct` and match a `direct` + * golden. `docs/adr/0013`, 2026-08-02 supplement. */ +#define EXTRACT_DIRECT 0u +#define EXTRACT_HIRES_EVEN 1u +static unsigned want_extract = EXTRACT_DIRECT; + static uint64_t last_frame_hash; static bool last_frame_ok; /* The canonical pixels behind `last_frame_hash`, kept so a disagreement can be diffed pixel by @@ -112,10 +121,77 @@ static bool environment(unsigned cmd, void *data) { } } +/* Read `build/scenes.tsv`'s fourth column into `out`, indexed by scene number - 1. + * + * The manifest sits next to the ROM and is written by the build that produced it, so it describes + * THIS cart's numbering. A missing file leaves everything `direct`, which is what every scene was + * before hi-res; an UNKNOWN rule is fatal, because falling back to `direct` would silently hash the + * left half of a hi-res picture and produce a golden that looks fine. */ +static void load_extractions(const char *rom_path, unsigned *out, unsigned n) { + for (unsigned i = 0; i < n; i++) { + out[i] = EXTRACT_DIRECT; + } + char path[1024]; + const char *slash = strrchr(rom_path, '/'); + size_t dir = slash ? (size_t)(slash - rom_path) + 1 : 0; + if (dir + strlen("scenes.tsv") + 1 > sizeof path) { + return; + } + memcpy(path, rom_path, dir); + strcpy(path + dir, "scenes.tsv"); + FILE *f = fopen(path, "r"); + if (!f) { + fprintf(stderr, "accuracysnes: no %s; every scene treated as `direct`\n", path); + return; + } + char line[512]; + while (fgets(line, sizeof line, f)) { + if (line[0] == '#') { + continue; + } + unsigned idx = 0; + char id[128], dossier[128], tag[64]; + if (sscanf(line, "%u\t%127[^\t]\t%127[^\t]\t%63s", &idx, id, dossier, tag) != 4) { + continue; + } + if (idx == 0 || idx > n) { + continue; + } + /* A trailing CR would make every tag compare unequal and take the fatal branch below, so + * a manifest checked out with CRLF endings would look like an unimplemented rule. */ + char *cr = strchr(tag, '\r'); + if (cr) { + *cr = '\0'; + } + if (strcmp(tag, "direct") == 0) { + out[idx - 1] = EXTRACT_DIRECT; + } else if (strcmp(tag, "hires-even") == 0) { + out[idx - 1] = EXTRACT_HIRES_EVEN; + } else { + fprintf(stderr, "accuracysnes: scenes.tsv declares extraction `%s` for scene %u, which " + "this host does not implement; falling back to `direct` would hash the " + "wrong region\n", tag, idx); + exit(2); + } + } + fclose(f); +} + /* FNV-1a over canonical 0RRRRRGGGGGBBBBB pixels — the same value the Rust harness computes from * its own BGR555 framebuffer, so the two are directly comparable. */ static void video_refresh(const void *d, unsigned w, unsigned h, size_t p) { - if (d && (w != SCENE_W || h != SCENE_H + FIRST_ROW)) { + /* What the declared extraction requires this frame to be. `hires-even` samples every other + * column of a 512-wide picture -- the subscreen half, the one all three references agree on -- + * and every other ROW where the host doubled the height too (Mesen2 line-doubles; snes9x, as + * measured 2026-08-02, emits 512x224 and does not). */ + const unsigned want_w = (want_extract == EXTRACT_HIRES_EVEN) ? SCENE_W * 2u : SCENE_W; + const unsigned xstep = (want_extract == EXTRACT_HIRES_EVEN) ? 2u : 1u; + const unsigned ystep = (d && h >= (SCENE_H + FIRST_ROW) * 2u) ? 2u : 1u; + /* The warn condition is the DROP condition, character for character. They disagreed in the + * first draft (`<` here, `!=` below), so a frame taller than the contract was dropped without a + * word -- the exact silent-failure shape this host's geometry checks exist to remove. If these + * two ever diverge again, the quiet one wins and the loud one is decoration. */ + if (d && (w != want_w || h != (SCENE_H + FIRST_ROW) * ystep)) { /* Logged ONCE per distinct geometry, not per frame: a rejected frame leaves the previous * hash standing, which is silent by design for duped frames but is exactly the wrong kind * of quiet for a geometry violation. MEASURED 2026-08-01: snes9x emits 256x224 normally and @@ -125,11 +201,13 @@ static void video_refresh(const void *d, unsigned w, unsigned h, size_t p) { if (w != last_w || h != last_h) { last_w = w; last_h = h; - fprintf(stderr, "accuracysnes: out-of-contract frame %ux%u (want %ux%u), not hashed\n", - w, h, SCENE_W, SCENE_H + FIRST_ROW); + fprintf(stderr, + "accuracysnes: out-of-contract frame %ux%u (want %ux%u for extraction %u), " + "not hashed\n", + w, h, want_w, (SCENE_H + FIRST_ROW) * ystep, want_extract); } } - if (!d || w != SCENE_W || h != SCENE_H + FIRST_ROW) { + if (!d || w != want_w || h != (SCENE_H + FIRST_ROW) * ystep) { /* A duped frame arrives as a NULL pointer; a hi-res or overscan frame is outside the * contract. Either way the previous hash stands rather than being silently replaced. * @@ -144,14 +222,14 @@ static void video_refresh(const void *d, unsigned w, unsigned h, size_t p) { } uint64_t hash = 0xcbf29ce484222325ull; for (unsigned y = 0; y < SCENE_H; y++) { - const uint8_t *row = (const uint8_t *)d + (size_t)(y + FIRST_ROW) * p; + const uint8_t *row = (const uint8_t *)d + (size_t)(y + FIRST_ROW) * ystep * p; for (unsigned x = 0; x < SCENE_W; x++) { unsigned r, g, b; if (pixel_format == FMT_XRGB8888) { - uint32_t v = ((const uint32_t *)row)[x]; + uint32_t v = ((const uint32_t *)row)[x * xstep]; r = (v >> 19) & 0x1F; g = (v >> 11) & 0x1F; b = (v >> 3) & 0x1F; } else { - uint16_t v = ((const uint16_t *)row)[x]; + uint16_t v = ((const uint16_t *)row)[x * xstep]; if (pixel_format == FMT_RGB565) { /* Green is 6 bits here because the core widened a 5-bit channel; dropping the * low bit recovers the original rather than inventing precision. */ @@ -396,6 +474,11 @@ int main(int argc, char **argv) { static bool got[MAX_SCENES]; static unsigned sightings[MAX_SCENES]; static uint16_t px[MAX_SCENES][SCENE_W * SCENE_H]; + /* Per-scene extraction, read from the manifest the SAME BUILD wrote next to the ROM. Read + * rather than hard-coded so this host cannot drift from the Lua and Rust ones; a rule this + * host does not implement is a hard error, never a fallback to `direct`. */ + static unsigned extract_of[MAX_SCENES]; + load_extractions(argv[2], extract_of, MAX_SCENES); unsigned scene_frames = 0; bool over_range = false; while (scene_frames < max_frames && wram[SCENE_DONE] != 0x5A) { @@ -419,7 +502,19 @@ int main(int argc, char **argv) { } if (id != 0) { sightings[id - 1]++; + /* Arm the extraction for the NEXT frame this scene renders. The ID is only + * readable after the run, and the capture takes the SECOND sighting, so the rule + * is always in place by the frame that is actually hashed. */ + want_extract = extract_of[id - 1]; } + /* DELIBERATELY STICKY -- do not "reset to direct when id == 0". That looks like an + * obvious tidy-up (it was suggested in review) and it silently breaks the hi-res + * capture: `run_scenes` publishes the scene ID only on frames whose field parity + * matches, so `id == 0` occurs INSIDE a hold, not merely between holds. Resetting there + * disarms the rule for the very frame that gets hashed, and snes9x stopped capturing + * the hi-res scene at all -- 54 match, 0 unblessed, the scene simply gone. Measured + * 2026-08-02. The cost of staying sticky is at most one spurious out-of-contract line + * at a hi-res-to-direct transition, which is a far better trade than a missing scene. */ /* The SECOND frame of the published window, by agreement with the in-repo harness. A * host samples WRAM at its own frame boundary, which need not be the one the cart's * vblank poll sees, so both ends of the window are at risk of being off by one — this diff --git a/scripts/accuracysnes/mesen_scenes.lua b/scripts/accuracysnes/mesen_scenes.lua index e83c60fb..fc598b26 100644 --- a/scripts/accuracysnes/mesen_scenes.lua +++ b/scripts/accuracysnes/mesen_scenes.lua @@ -38,6 +38,38 @@ local SCENE_H = 224 -- real planned work; it has to start from a loud rejection here, not a silent wrong picture. local SCENE_BUF_LEN = SCENE_W * 239 +-- Per-scene extraction, read from the manifest the SAME BUILD wrote next to the ROM +-- (`docs/adr/0013`, 2026-08-02 supplement). Read rather than hard-coded so this host cannot drift +-- from the C and Rust ones. A rule this host does not implement is a hard error, never a fallback +-- to `direct` -- falling back would silently hash the left half of a hi-res picture. +local EXTRACT = {} +local function loadExtractions(romPath) + local dir = romPath:match("^(.*)/[^/]*$") or "." + local f = io.open(dir .. "/scenes.tsv", "r") + if not f then + return + end + for line in f:lines() do + if line:sub(1, 1) ~= "#" then + -- `%S+` would keep a trailing CR from a CRLF checkout, and every tag would then + -- compare unequal and take the fatal branch below. + local idx, _, _, tag = line:match("^(%d+) ([^ ]*) ([^ ]*) (%S+)") + if tag then + tag = tag:gsub("\r$", "") + end + if idx then + if tag == "direct" or tag == "hires-even" then + EXTRACT[tonumber(idx)] = tag + else + print("ACCURACYSNES-SCENES-BADEXTRACT " .. tag) + emu.stop(252) + end + end + end + end + f:close() +end + -- The buffer row Mesen2's picture starts on. An output convention, exactly like pixel format: -- Mesen hands back 256x239 whose picture begins 7 rows in, snes9x's libretro core already starts -- at the first visible line, and RustySNES composites from scanline 0. Calibrated by comparing @@ -104,17 +136,25 @@ local function hashFrame(id) -- and the loop below then walks it with a stride of 256, hashing a diagonal slice of the -- picture while reporting success. A golden blessed from that would be stable, reproducible -- and wrong. Measured 2026-08-01: every current scene returns exactly 61184 = 256 x 239. - local width = SCENE_W - if #buf ~= SCENE_BUF_LEN then - print("ACCURACYSNES-SCENES-BADGEOMETRY " .. #buf .. " expected " .. SCENE_BUF_LEN) + -- `hires-even` samples every other column of a 512-wide picture -- the subscreen half -- and + -- every other ROW, because Mesen2 line-doubles a hi-res frame (measured 2026-08-02: it emits + -- 512x478 where snes9x emits 512x224). + local hires = EXTRACT[id] == "hires-even" + local width = hires and (SCENE_W * 2) or SCENE_W + local xstep = hires and 2 or 1 + local ystep = hires and 2 or 1 + local wantLen = hires and (SCENE_BUF_LEN * 4) or SCENE_BUF_LEN + if #buf ~= wantLen then + print("ACCURACYSNES-SCENES-BADGEOMETRY " .. #buf .. " expected " .. wantLen + .. " for extraction " .. (EXTRACT[id] or "direct")) return nil end local h = 0xcbf29ce484222325 local px = {} for y = 0, SCENE_H - 1 do - local row = (y + FIRST_ROW) * width + local row = (y + FIRST_ROW) * ystep * width for x = 0, SCENE_W - 1 do - local v = buf[row + x + 1] + local v = buf[row + x * xstep + 1] -- Mesen hands back 24-bit RGB; the SNES channels are 5-bit widened to 8, so the top -- five bits recover the original rather than inventing precision. local r = (v >> 19) & 0x1F @@ -191,5 +231,13 @@ local function onInput() up = false, down = false, left = false, right = false }, 0) end +-- The manifest lives next to the ROM the runner was pointed at. `emu.getRomInfo()` is how a Lua +-- script learns that path; without it this host would have to be told separately and could then be +-- reading a manifest from a different build than the ROM it is running. +local info = emu.getRomInfo and emu.getRomInfo() or nil +if info and info.path then + loadExtractions(info.path) +end + emu.addEventCallback(onInput, emu.eventType.inputPolled) emu.addEventCallback(onFrame, emu.eventType.endFrame) diff --git a/tests/roms/AccuracySNES/asm/scenes.s b/tests/roms/AccuracySNES/asm/scenes.s index c2ea5cd2..c5f626b0 100644 --- a/tests/roms/AccuracySNES/asm/scenes.s +++ b/tests/roms/AccuracySNES/asm/scenes.s @@ -787,6 +787,31 @@ SCENES_IMPL = 1 rts .endproc +; c5-mode5-hires-16px-tiles — C5.15 +; Mode 5, whose tiles are SIXTEEN pixels wide rather than eight — the canvas font rendered at double width across a 512-pixel picture. The first scene to declare a non-`Direct` extraction: the hosts do not agree on the SHAPE of a hi-res frame (snes9x emits 512x224, Mesen2 512x478 because it line-doubles), so the sample is the EVEN columns — the subscreen half, the one all three references agree on — and the even rows where the height doubled too. See the 2026-08-02 supplement in `docs/adr/0013`. +.proc scene_c5_mode5_hires_16px_tiles + .a16 + .i16 + sep #$20 + .a8 + lda #$05 + sta $2105 ; BGMODE 5 — 512-wide hi-res, 16-px-wide tiles + stz $210B + lda #(MAP_BASE >> 8) + sta $2107 + jsr scene_low_tiles ; the canvas map indexes past the font + sep #$20 + .a8 + lda #$01 + sta $212C ; BG1 only + lda #$0F + sta $2100 + rep #$30 + .a16 + .i16 + rts +.endproc + ; c5-mode2-plain — C5.03 ; Mode 2 with BG1 and BG2 displayed and offset-per-tile left inert (BG3's table is all zeroes, so no entry carries an enable bit). The control for the `c6-*` scenes: if this renders and they do not, the fault is in their setup rather than in the mode. It is also `C5.03`'s own evidence, which is why it is a scene and not a scratch file. .proc scene_c5_mode2_plain @@ -2291,7 +2316,7 @@ SCENES_IMPL = 1 .export _scene_count .export _scene_entries _scene_count: - .word 54 + .word 55 _scene_entries: .addr scene_c5_mode1_bg_priority .addr scene_c8_fixed_colour_add @@ -2311,6 +2336,7 @@ _scene_entries: .addr scene_c8_window_logic_xor .addr scene_c10_mosaic_screen_anchored .addr scene_c12_direct_colour_mode3 + .addr scene_c5_mode5_hires_16px_tiles .addr scene_c5_mode2_plain .addr scene_c6_opt_v_alternating_columns .addr scene_c6_opt_v_replaces_vofs diff --git a/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc b/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc index 190d8ed1..1429855a 100644 Binary files a/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc and b/tests/roms/AccuracySNES/build/accuracysnes-pal.sfc differ diff --git a/tests/roms/AccuracySNES/build/accuracysnes.sfc b/tests/roms/AccuracySNES/build/accuracysnes.sfc index 169ddb63..a26269c4 100644 Binary files a/tests/roms/AccuracySNES/build/accuracysnes.sfc and b/tests/roms/AccuracySNES/build/accuracysnes.sfc differ diff --git a/tests/roms/AccuracySNES/build/scenes.tsv b/tests/roms/AccuracySNES/build/scenes.tsv index 7c4a9d58..95597b72 100644 --- a/tests/roms/AccuracySNES/build/scenes.tsv +++ b/tests/roms/AccuracySNES/build/scenes.tsv @@ -1,55 +1,56 @@ -# GENERATED by accuracysnes-gen — index id dossier -1 c5-mode1-bg-priority C5.02 -2 c8-fixed-colour-add C8.11 -3 c10-mosaic-4x C10.01 -4 c5-mode0-four-bg-priority C5.01 -5 c5-mode0-palette-segregation C5.09 -6 c5-mode3-8bpp C5.04 -7 c5-tilemap-flip-bits C5.10 -8 c5-16x16-tiles C5.11 -9 c8-subtract-mode C8.10 -10 c8-clamp-no-wrap C8.02 -11 c8-half-ignored-on-fixed-backdrop C8.03 -12 c8-window-bounds-inclusive C8.04 -13 c8-window-left-gt-right-empty C8.05 -14 c8-window-inverted-empty-is-full C8.06 -15 c8-both-windows-disabled-empty C8.07 -16 c8-window-logic-xor C8.08 -17 c10-mosaic-screen-anchored C10.02 -18 c12-direct-colour-mode3 C12.01,C12.03 -19 c5-mode2-plain C5.03 -20 c6-opt-v-alternating-columns C6.05,C6.06 -21 c6-opt-v-replaces-vofs C6.04 -22 c6-opt-h-keeps-fine-scroll C6.03 -23 c6-opt-enable-bit-bg1 C6.01 -24 c6-opt-enable-bit-bg2 C6.01 -25 c6-mode4-h-vs-v-select C6.02 -26 c11-mode7-identity C5.08,C11.05 -27 c5-mode4-two-layers C5.05 -28 c5-bgsc-64x32-second-map-right C5.12 -29 c5-mode7-ignores-bgsc C5.13 -30 c11-org-13bit-mask C11.02 -31 c12-direct-colour-zero-is-transparent C12.02 -32 c11-mode7-rotate-scale C11.01 -33 c11-screen-over-wrap C11.04 -34 c11-screen-over-transparent C11.04 -35 c11-screen-over-char0 C11.04 -36 c11-mode7-screen-flip C11.01 -37 c11-extbg-priority-split C11.09 -38 c11-mode7-direct-colour C11.10,C12.03 -39 c10-mode7-mosaic C10.05 -40 c11-mode7-window C11.11 -41 c4-shared-scroll-latch C4.02,C4.03 -42 c4-hofs-keeps-low-three-bits C4.01 -43 c4-210d-drives-mode7-scroll C4.04,C4.05 -44 c7-lower-index-on-top C7.15 -45 c8-obj-math-palettes-4-7 C8.01 -46 c7-name-select-picks-second-table C7.11 -47 c7-objsel-size-6 C7.10 -48 c7-objsel-size-7 C7.10 -49 c7-vflip-tall-halves C7.13 -50 c7-64px-wraps-bottom-to-top C7.14 -51 c7-hflip-sliver-order C7.03 -52 c8-force-black-outside-window C8.12 -53 c5-4bpp-bitplane-order C5.14 -54 c11-mode7-product-low-bits-masked C11.03 +# GENERATED by accuracysnes-gen — index id dossier extract +1 c5-mode1-bg-priority C5.02 direct +2 c8-fixed-colour-add C8.11 direct +3 c10-mosaic-4x C10.01 direct +4 c5-mode0-four-bg-priority C5.01 direct +5 c5-mode0-palette-segregation C5.09 direct +6 c5-mode3-8bpp C5.04 direct +7 c5-tilemap-flip-bits C5.10 direct +8 c5-16x16-tiles C5.11 direct +9 c8-subtract-mode C8.10 direct +10 c8-clamp-no-wrap C8.02 direct +11 c8-half-ignored-on-fixed-backdrop C8.03 direct +12 c8-window-bounds-inclusive C8.04 direct +13 c8-window-left-gt-right-empty C8.05 direct +14 c8-window-inverted-empty-is-full C8.06 direct +15 c8-both-windows-disabled-empty C8.07 direct +16 c8-window-logic-xor C8.08 direct +17 c10-mosaic-screen-anchored C10.02 direct +18 c12-direct-colour-mode3 C12.01,C12.03 direct +19 c5-mode5-hires-16px-tiles C5.15 hires-even +20 c5-mode2-plain C5.03 direct +21 c6-opt-v-alternating-columns C6.05,C6.06 direct +22 c6-opt-v-replaces-vofs C6.04 direct +23 c6-opt-h-keeps-fine-scroll C6.03 direct +24 c6-opt-enable-bit-bg1 C6.01 direct +25 c6-opt-enable-bit-bg2 C6.01 direct +26 c6-mode4-h-vs-v-select C6.02 direct +27 c11-mode7-identity C5.08,C11.05 direct +28 c5-mode4-two-layers C5.05 direct +29 c5-bgsc-64x32-second-map-right C5.12 direct +30 c5-mode7-ignores-bgsc C5.13 direct +31 c11-org-13bit-mask C11.02 direct +32 c12-direct-colour-zero-is-transparent C12.02 direct +33 c11-mode7-rotate-scale C11.01 direct +34 c11-screen-over-wrap C11.04 direct +35 c11-screen-over-transparent C11.04 direct +36 c11-screen-over-char0 C11.04 direct +37 c11-mode7-screen-flip C11.01 direct +38 c11-extbg-priority-split C11.09 direct +39 c11-mode7-direct-colour C11.10,C12.03 direct +40 c10-mode7-mosaic C10.05 direct +41 c11-mode7-window C11.11 direct +42 c4-shared-scroll-latch C4.02,C4.03 direct +43 c4-hofs-keeps-low-three-bits C4.01 direct +44 c4-210d-drives-mode7-scroll C4.04,C4.05 direct +45 c7-lower-index-on-top C7.15 direct +46 c8-obj-math-palettes-4-7 C8.01 direct +47 c7-name-select-picks-second-table C7.11 direct +48 c7-objsel-size-6 C7.10 direct +49 c7-objsel-size-7 C7.10 direct +50 c7-vflip-tall-halves C7.13 direct +51 c7-64px-wraps-bottom-to-top C7.14 direct +52 c7-hflip-sliver-order C7.03 direct +53 c8-force-black-outside-window C8.12 direct +54 c5-4bpp-bitplane-order C5.14 direct +55 c11-mode7-product-low-bits-masked C11.03 direct diff --git a/tests/roms/AccuracySNES/gen/src/scenes.rs b/tests/roms/AccuracySNES/gen/src/scenes.rs index cdfaee01..a74f5086 100644 --- a/tests/roms/AccuracySNES/gen/src/scenes.rs +++ b/tests/roms/AccuracySNES/gen/src/scenes.rs @@ -31,6 +31,40 @@ use core::fmt::Write as _; +/// How a host turns the frame it emits into the canonical 256x224 sample (`docs/adr/0013` +/// supplement). +/// +/// Carried in `build/scenes.tsv` rather than compiled into any host, so the rule travels with the +/// scene and the C, Lua and Rust implementations cannot drift apart — the same reasoning that makes +/// `FIRST_ROW` a declared per-host constant rather than a guess. +/// +/// **A host that meets a value it does not implement must REJECT the scene, never fall back to +/// [`Extract::Direct`].** Falling back would silently hash the left half of a hi-res picture, which +/// is exactly the class of bug the exact-geometry checks were added to stop. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Extract { + /// The frame is 256 wide: take `SCENE_H` rows from `FIRST_ROW`. Every scene until hi-res, and + /// the default, so no existing golden moves. + Direct, + /// The frame is **512 wide**: take the even columns — the subscreen half, the one the three + /// references agree on — and the even rows as well on a host whose height also doubled. + /// + /// Both halves are needed because the hosts disagree about the *shape* of a hi-res frame, not + /// just its pixels: measured 2026-08-02, snes9x emits `512x224` and Mesen2 `512x478`, because + /// Mesen2 line-doubles and snes9x does not. + HiResEven, +} + +impl Extract { + /// The manifest spelling. Parsed by every host, so it is part of the format. + const fn tag(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::HiResEven => "hires-even", + } + } +} + /// One rendered scene: a name, the assertion it covers, and the setup it performs. pub struct Scene { /// Stable identifier, used as the golden's key. Never renumber — the golden is keyed on it. @@ -44,6 +78,8 @@ pub struct Scene { /// scene ends by writing `INIDISP` ($2100), because brightness is part of what a scene may /// want to vary. Omit that write and the scene renders black. pub setup: &'static [&'static str], + /// How a host samples this scene's frame. [`Extract::Direct`] for everything that is not hi-res. + pub extract: Extract, } /// The scene set. Deliberately small to begin with — ADR 0013 gates only cross-validated scenes, @@ -70,6 +106,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100 ; brightness 15, forced blank off", ], + extract: Extract::Direct, }, Scene { id: "c8-fixed-colour-add", @@ -92,6 +129,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c10-mosaic-4x", @@ -110,6 +148,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-mode0-four-bg-priority", @@ -143,6 +182,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-mode0-palette-segregation", @@ -177,6 +217,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-mode3-8bpp", @@ -199,6 +240,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-tilemap-flip-bits", @@ -255,6 +297,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-16x16-tiles", @@ -275,6 +318,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-subtract-mode", @@ -299,6 +343,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-clamp-no-wrap", @@ -324,6 +369,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-half-ignored-on-fixed-backdrop", @@ -349,6 +395,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-window-bounds-inclusive", @@ -376,6 +423,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-window-left-gt-right-empty", @@ -402,6 +450,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-window-inverted-empty-is-full", @@ -429,6 +478,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-both-windows-disabled-empty", @@ -455,6 +505,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-window-logic-xor", @@ -488,6 +539,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c10-mosaic-screen-anchored", @@ -516,6 +568,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c12-direct-colour-mode3", @@ -539,6 +592,33 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, + }, + Scene { + id: "c5-mode5-hires-16px-tiles", + dossier: "C5.15", + what: "Mode 5, whose tiles are SIXTEEN pixels wide rather than eight — the canvas font \ + rendered at double width across a 512-pixel picture. The first scene to declare a \ + non-`Direct` extraction: the hosts do not agree on the SHAPE of a hi-res frame \ + (snes9x emits 512x224, Mesen2 512x478 because it line-doubles), so the sample is \ + the EVEN columns — the subscreen half, the one all three references agree on — and \ + the even rows where the height doubled too. See the 2026-08-02 supplement in \ + `docs/adr/0013`.", + setup: &[ + "sep #$20", + "lda #$05", + "sta $2105 ; BGMODE 5 — 512-wide hi-res, 16-px-wide tiles", + "stz $210B", + "lda #(MAP_BASE >> 8)", + "sta $2107", + "jsr scene_low_tiles ; the canvas map indexes past the font", + "sep #$20", + "lda #$01", + "sta $212C ; BG1 only", + "lda #$0F", + "sta $2100", + ], + extract: Extract::HiResEven, }, Scene { id: "c5-mode2-plain", @@ -572,6 +652,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-opt-v-alternating-columns", @@ -612,6 +693,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-opt-v-replaces-vofs", @@ -650,6 +732,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-opt-h-keeps-fine-scroll", @@ -688,6 +771,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-opt-enable-bit-bg1", @@ -726,6 +810,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-opt-enable-bit-bg2", @@ -763,6 +848,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c6-mode4-h-vs-v-select", @@ -798,6 +884,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-identity", @@ -832,6 +919,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-mode4-two-layers", @@ -866,6 +954,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-bgsc-64x32-second-map-right", @@ -895,6 +984,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c5-mode7-ignores-bgsc", @@ -936,6 +1026,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-org-13bit-mask", @@ -985,6 +1076,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c12-direct-colour-zero-is-transparent", @@ -1014,6 +1106,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-rotate-scale", @@ -1054,6 +1147,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-screen-over-wrap", @@ -1087,6 +1181,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-screen-over-transparent", @@ -1120,6 +1215,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-screen-over-char0", @@ -1154,6 +1250,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-screen-flip", @@ -1188,6 +1285,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-extbg-priority-split", @@ -1224,6 +1322,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-direct-colour", @@ -1259,6 +1358,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c10-mode7-mosaic", @@ -1294,6 +1394,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-window", @@ -1340,6 +1441,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c4-shared-scroll-latch", @@ -1364,6 +1466,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c4-hofs-keeps-low-three-bits", @@ -1388,6 +1491,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c4-210d-drives-mode7-scroll", @@ -1425,6 +1529,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-lower-index-on-top", @@ -1470,6 +1575,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-obj-math-palettes-4-7", @@ -1517,6 +1623,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-name-select-picks-second-table", @@ -1558,6 +1665,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-objsel-size-6", @@ -1605,6 +1713,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-objsel-size-7", @@ -1651,6 +1760,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-vflip-tall-halves", @@ -1694,6 +1804,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-64px-wraps-bottom-to-top", @@ -1732,6 +1843,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c7-hflip-sliver-order", @@ -1774,6 +1886,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, Scene { id: "c8-force-black-outside-window", @@ -1805,6 +1918,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100 ; brightness 15, forced blank off", ], + extract: Extract::Direct, }, Scene { id: "c5-4bpp-bitplane-order", @@ -1891,6 +2005,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100 ; brightness 15, forced blank off", ], + extract: Extract::Direct, }, Scene { id: "c11-mode7-product-low-bits-masked", @@ -1925,6 +2040,7 @@ pub const SCENES: &[Scene] = &[ "lda #$0F", "sta $2100", ], + extract: Extract::Direct, }, ]; @@ -2412,9 +2528,16 @@ pub fn asm() -> String { /// stable names, written next to the ROM by the same build that produced it. #[must_use] pub fn manifest() -> String { - let mut s = String::from("# GENERATED by accuracysnes-gen — index\tid\tdossier\n"); + let mut s = String::from("# GENERATED by accuracysnes-gen — index\tid\tdossier\textract\n"); for (i, sc) in SCENES.iter().enumerate() { - let _ = writeln!(s, "{}\t{}\t{}", i + 1, sc.id, sc.dossier); + let _ = writeln!( + s, + "{}\t{}\t{}\t{}", + i + 1, + sc.id, + sc.dossier, + sc.extract.tag() + ); } s }