Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 79 additions & 7 deletions crates/rustysnes-test-harness/tests/accuracysnes_scenes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8, Extract> {
// 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
Expand Down Expand Up @@ -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<u16>) {
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<u16>) {
// 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 \
Expand All @@ -126,7 +193,10 @@ fn hash_scene(fb: &[u16], width: usize) -> (u64, Vec<u16>) {
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);
Expand Down Expand Up @@ -167,6 +237,7 @@ fn capture_scenes() -> BTreeMap<u8, u64> {

let mut seen: BTreeMap<u8, u64> = BTreeMap::new();
let mut sightings: BTreeMap<u8, u32> = BTreeMap::new();
let extracts = extractions();
let mut current = 0u8;
let mut battery_done = false;

Expand All @@ -188,7 +259,8 @@ fn capture_scenes() -> BTreeMap<u8, u64> {
// 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
Expand Down
1 change: 1 addition & 0 deletions docs/accuracysnes-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/adr/0013-accuracysnes-framebuffer-oracle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
109 changes: 102 additions & 7 deletions scripts/accuracysnes/libretro_crossval.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Comment on lines +127 to +146
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/* 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
Expand All @@ -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.
*
Expand All @@ -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. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Loading
Loading