diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 01eb5d27..caa7c7f7 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -181,6 +181,61 @@ re-dispatches the guest's true target. Counters in --- +## `frame_fingerprint` — per-frame guest-write fingerprint (native only) + +Cumulative write hashes, snapshotted at every VBlank into a 32768-frame ring +(`runtime/include/frame_fingerprint.h`). Diff two runs of the same seeded +input, such as native overlay shards against the interpreter or two builds, to +find the first frame where guest behaviour forks. Then arm +`record_frame` on that frame in both runs and compare the two ordered logs. + +- `{"cmd":"frame_fingerprint","count":1024,"frame_lo":N,"frame_hi":M}`: all + parameters are optional. Entries come back oldest first. + +| Column | Role | Covers | +|---|---|---| +| `cyc` | judge | guest cycle counter at the snapshot | +| `wc`, `ws` | judge | main-RAM write count, and an order-independent sum over `(addr, value)` | +| `mmio`, `mc` | judge | device-register writes: ordered hash over `(addr, value, store PC)`, and a count | +| `sp`, `sc` | judge | scratchpad writes: ordered hash over `(addr, value, store PC)`, and a count | +| `qc` | judge | writes that FMV-quiet kept out of every other column | +| `wr`, `pc` | locator | main-RAM writes: ordered hash over `(addr, value)`, and an ordered hash over store PCs | + +**Judge on the judge columns.** Two runs that behaved the same agree on all of +them at every frame. `wr` and `pc` can differ even when guest state is +identical. DMA and device writes to RAM (MDEC-out, CD, SPU, GPU→RAM) are +recorded in the order the host services the device. A native shard flushes +cycles at every store barrier, but batched interpreted and static code +services the device after the block. As a result, the same writes can +interleave differently with CPU stores. A device write also takes whatever +CPU store PC came last. Use `wr` and `pc` only to narrow down a fork once a +judge column has found it. + +**Store PCs are exact on every backend.** Static code and the interpreter set +`g_debug_last_store_pc` themselves. Native overlay shards write the runtime's +copy through the ABI v24 `last_store_pc` pointer. Before v24, shards kept a +private copy, so `pc`, `mmio` and `sp` named an older store for every overlay +store. + +**One-frame straddles are not divergences.** Batched code services devices up +to a basic block late. So a device write due at, for example, VBlank + 1 cycle +can land on the other side of the snapshot in one run. `ws` and `wc` then +differ for that single frame, with `wc` off by the number of straddled writes, +and agree again at the next one. In a 12000-frame R4 A/B of native shards +against the interpreter, 497 frames straddled by exactly one write, and every +one re-converged on the next frame. + +**Turn FMV-quiet off for identity runs.** `PSX_DEBUG_FMV_QUIET` is on unless it +is set to `0`. While the MDEC has decoded recently, it stops write recording, +and those frames' writes add only to `qc`. If a straddled write falls into a +quiet frame in one run and not the other, `ws`, `wc` and `qc` never agree +again. `wc + qc` still agrees, which tells this case apart from a real fork. +For A/B identity, set `PSX_DEBUG_FMV_QUIET=0` in both runs. + +`ws` is a multiset sum. It cannot see two writes to one address arriving in +the opposite order, even though the final RAM differs. Such a fork shows up in +later writes, `cyc`, or `read_ram`. + ## `bios_info` — linked recompiled-BIOS identity (native only) Reports which BIOS image this build's recompiled C was generated from @@ -338,7 +393,7 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i **316 commands registered** — 303 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -54 of 316 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +55 of 316 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -451,7 +506,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `fntrace_reset` | | ✓ | | | `fntrace_unfiltered` | | ✓ | | | `frame` | ✓ | | ✓ | -| `frame_fingerprint` | ✓ | | | +| `frame_fingerprint` | ✓ | | ✓ | | `frame_perf` | ✓ | | | | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0a3a52db..918ed637 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -710,6 +710,11 @@ if(BUILD_TESTING) add_test(NAME overlay_store_pc_forwarding_test COMMAND overlay_store_pc_forwarding_test) + # frame_fingerprint judge columns are blind to device-write service order. + add_executable(frame_fingerprint_test tests/test_frame_fingerprint.c) + target_include_directories(frame_fingerprint_test PRIVATE include) + add_test(NAME frame_fingerprint_test COMMAND frame_fingerprint_test) + add_executable(psx_cycle_event_boundaries_test tests/test_psx_cycle_event_boundaries.c src/psx_cycles.c) diff --git a/runtime/include/frame_fingerprint.h b/runtime/include/frame_fingerprint.h new file mode 100644 index 00000000..8c80ac50 --- /dev/null +++ b/runtime/include/frame_fingerprint.h @@ -0,0 +1,113 @@ +#ifndef FRAME_FINGERPRINT_H +#define FRAME_FINGERPRINT_H + +/* Per-frame guest-write fingerprint accumulator behind the debug server's + * `frame_fingerprint` command (debug_server.c snapshots it once per frame). + * Header-only so the hash semantics are unit-tested without the server + * (runtime/tests/test_frame_fingerprint.c). + * + * Two kinds of column, because an A/B run needs both: + * + * JUDGE columns say whether two runs did the same guest-visible things: + * wcount + wsum main RAM: count and ORDER-INDEPENDENT sum of (phys,val) + * sp_*, mmio_* scratchpad / device registers: CPU-only traffic, so the + * ordered hash is program order (includes the store PC) + * quiet_count writes FMV-quiet kept out of every column + * + * LOCATOR columns help find WHERE two runs parted, but can differ while + * guest state is identical: + * wr_hash, pc_hash ORDERED rolling hashes over main RAM. DMA/device + * writes (MDEC-out, CD, SPU, GPU->RAM) land in host + * device-service order, which depends on how often a + * backend flushes cycles, so the same multiset of writes + * can arrive interleaved differently with CPU stores. + * A device write also carries the last CPU store PC. + */ + +#include + +#define PSX_FP_FNV_SEED 1469598103934665603ULL +#define PSX_FP_FNV_PRIME 1099511628211ULL + +#define PSX_FP_SCRATCH_LO 0x1F800000u +#define PSX_FP_SCRATCH_HI 0x1F8003FFu + +typedef struct PsxFrameFingerprint { + uint64_t wr_hash; /* locator: ordered (phys,val), main RAM */ + uint64_t pc_hash; /* locator: ordered store PC, main RAM */ + uint64_t wcount; /* judge: main-RAM write count */ + uint64_t wsum; /* judge: order-independent sum over main RAM */ + uint64_t mmio_hash; /* judge: ordered (addr,val,pc), device registers */ + uint64_t mmio_count; + uint64_t sp_hash; /* judge: ordered (phys,val,pc), scratchpad */ + uint64_t sp_count; + uint64_t quiet_count; /* judge: fingerprint-eligible writes FMV-quiet skipped */ +} PsxFrameFingerprint; + +#define PSX_FRAME_FINGERPRINT_INIT \ + { PSX_FP_FNV_SEED, PSX_FP_FNV_SEED, 0, 0, PSX_FP_FNV_SEED, 0, \ + PSX_FP_FNV_SEED, 0, 0 } + +/* One write's contribution to wsum: the splitmix64 finalizer of (phys,val). + * It is a bijection on the 64-bit key, so distinct (phys,val) pairs never + * share a term, and a value moved between two addresses changes the sum. + * Summing (mod 2^64) makes the column blind to order only. */ +static inline uint64_t psx_fp_write_term(uint32_t phys, uint32_t val) +{ + uint64_t z = (((uint64_t)phys << 32) | val) + 0x9E3779B97F4A7C15ULL; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; + return z ^ (z >> 31); +} + +/* Main RAM (phys < ram_live_bytes) and scratchpad feed the fingerprint; + * anything else a RAM write path reports is ignored. */ +static inline int psx_fp_write_eligible(uint32_t phys, uint32_t ram_live_bytes) +{ + return phys < ram_live_bytes || + (phys >= PSX_FP_SCRATCH_LO && phys <= PSX_FP_SCRATCH_HI); +} + +static inline void psx_fp_record_write(PsxFrameFingerprint *fp, uint32_t phys, + uint32_t val, uint32_t pc, + uint32_t ram_live_bytes) +{ + if (phys >= PSX_FP_SCRATCH_LO && phys <= PSX_FP_SCRATCH_HI) { + uint64_t s = fp->sp_hash; + s = (s ^ (uint64_t)phys) * PSX_FP_FNV_PRIME; + s = (s ^ (uint64_t)val) * PSX_FP_FNV_PRIME; + s = (s ^ (uint64_t)pc) * PSX_FP_FNV_PRIME; + fp->sp_hash = s; + fp->sp_count++; + return; + } + if (phys >= ram_live_bytes) return; + uint64_t h = fp->wr_hash; + h = (h ^ (uint64_t)phys) * PSX_FP_FNV_PRIME; + h = (h ^ (uint64_t)val) * PSX_FP_FNV_PRIME; + fp->wr_hash = h; + fp->pc_hash = (fp->pc_hash ^ (uint64_t)pc) * PSX_FP_FNV_PRIME; + fp->wcount++; + fp->wsum += psx_fp_write_term(phys, val); +} + +static inline void psx_fp_record_mmio(PsxFrameFingerprint *fp, uint32_t addr, + uint32_t val, uint32_t pc) +{ + uint64_t h = fp->mmio_hash; + h = (h ^ (uint64_t)addr) * PSX_FP_FNV_PRIME; + h = (h ^ (uint64_t)val) * PSX_FP_FNV_PRIME; + h = (h ^ (uint64_t)pc) * PSX_FP_FNV_PRIME; + fp->mmio_hash = h; + fp->mmio_count++; +} + +/* A write FMV-quiet kept out of the fingerprint. Counting it makes the + * suppression visible: two runs whose quiet_count differs dropped different + * writes, so their cumulative columns stop being comparable from there on. */ +static inline void psx_fp_note_quiet(PsxFrameFingerprint *fp) +{ + fp->quiet_count++; +} + +#endif /* FRAME_FINGERPRINT_H */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 655d03f4..32fca386 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -49,6 +49,7 @@ #include "gpu_gl_renderer.h" #include "lockstep.h" #include "guest_tty.h" +#include "frame_fingerprint.h" #include #include @@ -153,58 +154,26 @@ int debug_server_fmv_quiet(void) uint64_t s_frame_count = 0; /* ---- Layer-1 first-divergence per-frame fingerprint ---------------------- - * Cumulative, ORDER-DEPENDENT rolling hashes over MAIN-RAM guest writes. - * The point: native and dirty-interp are the SAME deterministic program run - * two ways; they must produce an identical sequence of (addr,val,store_pc) - * guest-RAM writes until a codegen/timing bug forks them. A guest RAM write - * (and the PC issuing it) is pure guest semantics — granularity-invariant - * across native vs interp (unlike host block/dispatch counts). We accumulate - * two rolling hashes and snapshot them once per guest frame. Diff two runs' - * per-frame columns: the FIRST frame whose hash differs is the first-divergence - * frame, found in O(1) instead of O(n) function guesses. wr_hash vs pc_hash - * classifies the fork: pc differs but wr matches => same writes via a different - * control path; wr differs => actual state divergence. Reusable for any title. - * Hashed over live main RAM (phys < live RAM end) only — game state lives there; MMIO/ - * scratchpad churn (device polling) would add benign cross-backend noise. */ -uint64_t g_fp_wr_hash = 1469598103934665603ULL; /* FNV-1a-style seed (main RAM) */ -uint64_t g_fp_pc_hash = 1469598103934665603ULL; /* store-PC path sig (main RAM) */ -uint64_t g_fp_write_count = 0; -uint64_t g_fp_mmio_hash = 1469598103934665603ULL; /* SEPARATE: device-register writes */ -uint64_t g_fp_mmio_count = 0; -uint64_t g_fp_sp_hash = 1469598103934665603ULL; /* SEPARATE: scratchpad (0x1F8000xx) writes */ -uint64_t g_fp_sp_count = 0; + * Cumulative hashes over guest writes, snapshotted once per guest frame. + * Native and dirty-interp are the SAME deterministic program run two ways; + * diff two runs' per-frame columns and the FIRST frame that differs is the + * first-divergence frame, found in O(1) instead of O(n) function guesses. + * Column semantics (which ones judge guest state, which only locate a fork) + * live in frame_fingerprint.h and docs/TCP_COMMANDS.md. Reusable for any + * title. */ +static PsxFrameFingerprint s_fp = PSX_FRAME_FINGERPRINT_INIT; #define FP_RING_CAP 32768 -typedef struct { uint32_t frame; uint64_t wr_hash; uint64_t pc_hash; uint64_t wcount; - uint64_t mmio_hash; uint64_t mmio_count; - uint64_t sp_hash; uint64_t sp_count; uint64_t cyc; } FpEntry; +typedef struct { uint32_t frame; PsxFrameFingerprint fp; uint64_t cyc; } FpEntry; static PSX_BSS FpEntry s_fp_ring[FP_RING_CAP]; static uint32_t s_fp_head = 0; static uint64_t s_fp_total = 0; -/* Record a guest WRITE into the per-frame fingerprint. Live main RAM - * feeds the proven wr/pc hashes. Scratchpad (0x1F800000..0x1F8003FF) feeds a - * SEPARATE sp_hash — it was previously dropped entirely (the blind spot that - * hid a possible pre-1823 scratchpad-state fork), but folding it into wr_hash - * would pollute the main-RAM signal with benign device-poll churn, so it gets - * its own classified column instead. */ +/* Record a guest WRITE into the per-frame fingerprint. Live main RAM feeds + * the wr/pc/wc/ws columns; scratchpad (0x1F800000..0x1F8003FF) feeds its own + * sp column so device-poll churn there cannot pollute the main-RAM signal. */ static inline void fp_record_write(uint32_t phys, uint32_t val, uint32_t pc) { - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { /* scratchpad — separate hash */ - uint64_t s = g_fp_sp_hash; - s = (s ^ (uint64_t)phys) * 1099511628211ULL; - s = (s ^ (uint64_t)val) * 1099511628211ULL; - s = (s ^ (uint64_t)pc) * 1099511628211ULL; - g_fp_sp_hash = s; - g_fp_sp_count++; - return; - } - if (phys >= psx_ram_live_bytes()) return; /* main RAM only */ - uint64_t h = g_fp_wr_hash; - h = (h ^ (uint64_t)phys) * 1099511628211ULL; - h = (h ^ (uint64_t)val) * 1099511628211ULL; - g_fp_wr_hash = h; - g_fp_pc_hash = (g_fp_pc_hash ^ (uint64_t)pc) * 1099511628211ULL; - g_fp_write_count++; + psx_fp_record_write(&s_fp, phys, val, pc, psx_ram_live_bytes()); } /* MMIO/device-register write signature — separate hash so the diff can tell a @@ -213,25 +182,14 @@ static inline void fp_record_write(uint32_t phys, uint32_t val, uint32_t pc) * only sees the RAM AFTERMATH of a CD/IRQ-register divergence. */ static inline void fp_record_mmio(uint32_t addr, uint32_t val, uint32_t pc) { - uint64_t h = g_fp_mmio_hash; - h = (h ^ (uint64_t)addr) * 1099511628211ULL; - h = (h ^ (uint64_t)val) * 1099511628211ULL; - h = (h ^ (uint64_t)pc) * 1099511628211ULL; - g_fp_mmio_hash = h; - g_fp_mmio_count++; + psx_fp_record_mmio(&s_fp, addr, val, pc); } static void fp_snapshot(uint32_t frame) { FpEntry *e = &s_fp_ring[s_fp_head]; - e->frame = frame; - e->wr_hash = g_fp_wr_hash; - e->pc_hash = g_fp_pc_hash; - e->wcount = g_fp_write_count; - e->mmio_hash = g_fp_mmio_hash; - e->mmio_count = g_fp_mmio_count; - e->sp_hash = g_fp_sp_hash; - e->sp_count = g_fp_sp_count; + e->frame = frame; + e->fp = s_fp; { extern uint64_t psx_get_cycle_count(void); e->cyc = psx_get_cycle_count(); } s_fp_head = (s_fp_head + 1) % FP_RING_CAP; s_fp_total++; @@ -5023,10 +4981,13 @@ static void handle_frame(int id, const char *json) /* Layer-1 first-divergence: dump the per-frame write fingerprint ring. * Params: count (default 1024), frame_lo / frame_hi (optional inclusive - * filter). Entries are oldest-first within the window. Diff the wr/pc columns - * of two runs (native vs interp/oracle): the first frame whose wr or pc differs - * is the first-divergence frame. Small integer fields only — no large/ragged + * filter). Entries are oldest-first within the window. Judge two runs + * (native vs interp/oracle) on cyc/wc/ws/mmio/mc/sp/sc/qc; wr/pc are ordered + * locators (frame_fingerprint.h). Small integer fields only — no large/ragged * payload, so it never trips the trace-dump JSON/eviction problems. */ +/* Longest entry the format below can produce: every hash at 16 hex digits and + * every counter at 20 decimal digits comes to 284 bytes with the separator. */ +#define FP_JSON_ENTRY_MAX 320 static void handle_frame_fingerprint(int id, const char *json) { int count = json_get_int(json, "count", 1024); @@ -5038,7 +4999,7 @@ static void handle_frame_fingerprint(int id, const char *json) uint32_t avail = (s_fp_total < FP_RING_CAP) ? (uint32_t)s_fp_total : FP_RING_CAP; uint32_t start = (s_fp_total < FP_RING_CAP) ? 0 : s_fp_head; - size_t BUF = 512 + (size_t)count * 224; + size_t BUF = 512 + (size_t)count * FP_JSON_ENTRY_MAX; char *out = (char *)malloc(BUF); if (!out) { send_err(id, "oom"); return; } size_t pos = 0; @@ -5052,16 +5013,19 @@ static void handle_frame_fingerprint(int id, const char *json) if (fhi >= 0 && (int)e->frame > fhi) continue; pos += snprintf(out + pos, BUF - pos, "%s{\"frame\":%u,\"wr\":\"0x%016llx\",\"pc\":\"0x%016llx\",\"wc\":%llu," + "\"ws\":\"0x%016llx\"," "\"mmio\":\"0x%016llx\",\"mc\":%llu," - "\"sp\":\"0x%016llx\",\"sc\":%llu,\"cyc\":%llu}", + "\"sp\":\"0x%016llx\",\"sc\":%llu,\"qc\":%llu,\"cyc\":%llu}", emitted ? "," : "", e->frame, - (unsigned long long)e->wr_hash, - (unsigned long long)e->pc_hash, - (unsigned long long)e->wcount, - (unsigned long long)e->mmio_hash, - (unsigned long long)e->mmio_count, - (unsigned long long)e->sp_hash, - (unsigned long long)e->sp_count, + (unsigned long long)e->fp.wr_hash, + (unsigned long long)e->fp.pc_hash, + (unsigned long long)e->fp.wcount, + (unsigned long long)e->fp.wsum, + (unsigned long long)e->fp.mmio_hash, + (unsigned long long)e->fp.mmio_count, + (unsigned long long)e->fp.sp_hash, + (unsigned long long)e->fp.sp_count, + (unsigned long long)e->fp.quiet_count, (unsigned long long)e->cyc); emitted++; } @@ -9621,7 +9585,10 @@ void debug_server_trace_write_check(uint32_t phys, uint32_t old_val, (void)phys; (void)old_val; (void)new_val; (void)width; return; #endif - if (s_fmv_quiet) return; + if (s_fmv_quiet) { + if (psx_fp_write_eligible(phys, psx_ram_live_bytes())) psx_fp_note_quiet(&s_fp); + return; + } if (is_card_critical_addr(phys)) card_trace_record(phys, old_val, new_val, width); fp_record_write(phys, new_val, g_debug_last_store_pc); { @@ -9650,7 +9617,7 @@ void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width) (void)addr; (void)val; (void)width; return; #endif - if (s_fmv_quiet) return; + if (s_fmv_quiet) { psx_fp_note_quiet(&s_fp); return; } /* First-divergence fingerprint + frame recorder also see device writes. */ fp_record_mmio(addr, val, g_debug_last_store_pc); rec_event(REC_KIND_MMIO_W, addr, val, g_debug_last_store_pc, diff --git a/runtime/tests/test_frame_fingerprint.c b/runtime/tests/test_frame_fingerprint.c new file mode 100644 index 00000000..8221269a --- /dev/null +++ b/runtime/tests/test_frame_fingerprint.c @@ -0,0 +1,123 @@ +/* frame_fingerprint.h: the judge columns (wc/ws, sp, mmio, qc) must agree for + * two runs that performed the same guest writes, however a backend interleaved + * device writes with CPU stores, and must still tell different writes apart. + * The ordered wr column is the locator that is allowed to differ. + * + * The streams below model the R4 (SLUS-00797) case that motivated ws: a native + * overlay shard flushes cycles at every store barrier, so MDEC-out DMA words + * land between its CPU stores, while batched interpreted/static code services + * the same DMA after the block. */ +#include +#include + +#include "frame_fingerprint.h" + +#define LIVE_RAM 0x00200000u + +typedef struct { uint32_t phys, val, pc; } Write; + +static void replay(PsxFrameFingerprint *fp, const Write *w, int n) +{ + for (int i = 0; i < n; i++) + psx_fp_record_write(fp, w[i].phys, w[i].val, w[i].pc, LIVE_RAM); +} + +int main(void) +{ + /* CPU stores A, B; DMA words C, D (DMA writes carry whatever the last CPU + * store PC was when the device was serviced). */ + const Write shard[] = { + { 0x00010000u, 1u, 0x80065800u }, + { 0x000F0000u, 3u, 0x80065800u }, /* DMA, serviced at the barrier */ + { 0x00010004u, 2u, 0x80065804u }, + { 0x000F0004u, 4u, 0x80065804u }, /* DMA */ + }; + const Write batched[] = { + { 0x00010000u, 1u, 0x80065800u }, + { 0x00010004u, 2u, 0x80065804u }, + { 0x000F0000u, 3u, 0x80065804u }, /* DMA, serviced after the block */ + { 0x000F0004u, 4u, 0x80065804u }, + }; + PsxFrameFingerprint a = PSX_FRAME_FINGERPRINT_INIT; + PsxFrameFingerprint b = PSX_FRAME_FINGERPRINT_INIT; + replay(&a, shard, 4); + replay(&b, batched, 4); + assert(a.wcount == 4 && b.wcount == 4); + assert(a.wsum == b.wsum); /* judge: same guest writes */ + assert(a.wr_hash != b.wr_hash); /* locator: service order differs */ + assert(a.pc_hash != b.pc_hash); + + /* ws still separates different writes: a changed value, and the same two + * values swapped between two addresses. */ + { + const Write changed[] = { + { 0x00010000u, 1u, 0 }, { 0x00010004u, 5u, 0 }, + { 0x000F0000u, 3u, 0 }, { 0x000F0004u, 4u, 0 }, + }; + const Write swapped[] = { + { 0x00010000u, 2u, 0 }, { 0x00010004u, 1u, 0 }, + { 0x000F0000u, 3u, 0 }, { 0x000F0004u, 4u, 0 }, + }; + PsxFrameFingerprint c = PSX_FRAME_FINGERPRINT_INIT; + PsxFrameFingerprint d = PSX_FRAME_FINGERPRINT_INIT; + replay(&c, changed, 4); + replay(&d, swapped, 4); + assert(c.wsum != b.wsum); + assert(d.wsum != b.wsum); + } + + /* A write that straddles a frame snapshot: one backend services it before + * VBlank, the other after. The cumulative ws/wc differ for that one frame + * and agree again at the next snapshot. */ + { + PsxFrameFingerprint early = PSX_FRAME_FINGERPRINT_INIT; + PsxFrameFingerprint late = PSX_FRAME_FINGERPRINT_INIT; + replay(&early, batched, 3); /* frame N: DMA word C in */ + replay(&late, batched, 2); /* frame N: DMA word C late */ + PsxFrameFingerprint snap_early = early, snap_late = late; + assert(snap_early.wsum != snap_late.wsum); + assert(snap_early.wcount == snap_late.wcount + 1); + replay(&early, batched + 3, 1); /* frame N+1 */ + replay(&late, batched + 2, 2); + assert(early.wsum == late.wsum && early.wcount == late.wcount); + } + + /* FMV-quiet: a suppressed write changes nothing but qc. If the straddled + * write above falls into a quiet frame for one run only, ws never agrees + * again, and qc is where that shows. */ + { + PsxFrameFingerprint recorded = PSX_FRAME_FINGERPRINT_INIT; + PsxFrameFingerprint quiet = PSX_FRAME_FINGERPRINT_INIT; + replay(&recorded, batched, 4); + replay(&quiet, batched, 2); + replay(&quiet, batched + 3, 1); + assert(psx_fp_write_eligible(batched[2].phys, LIVE_RAM)); + psx_fp_note_quiet(&quiet); + assert(quiet.quiet_count == 1 && recorded.quiet_count == 0); + assert(quiet.wsum != recorded.wsum); + assert(quiet.wcount + quiet.quiet_count == recorded.wcount); + /* Only writes the fingerprint would have recorded are eligible. */ + assert(psx_fp_write_eligible(0x1F800010u, LIVE_RAM)); + assert(!psx_fp_write_eligible(LIVE_RAM, LIVE_RAM)); + assert(!psx_fp_write_eligible(0x1F801810u, LIVE_RAM)); + } + + /* Scratchpad and MMIO keep their own ordered columns (program order, so the + * store PC is part of the signature); main RAM beyond the live size is + * ignored. */ + { + PsxFrameFingerprint fp = PSX_FRAME_FINGERPRINT_INIT; + const PsxFrameFingerprint seed = PSX_FRAME_FINGERPRINT_INIT; + psx_fp_record_write(&fp, LIVE_RAM, 7u, 0x80010000u, LIVE_RAM); + assert(fp.wcount == 0 && fp.wsum == 0 && fp.wr_hash == seed.wr_hash); + psx_fp_record_write(&fp, 0x1F800010u, 7u, 0x80010000u, LIVE_RAM); + assert(fp.sp_count == 1 && fp.wcount == 0 && fp.wsum == 0); + uint64_t sp = fp.sp_hash; + PsxFrameFingerprint other_pc = PSX_FRAME_FINGERPRINT_INIT; + psx_fp_record_write(&other_pc, 0x1F800010u, 7u, 0x80010004u, LIVE_RAM); + assert(other_pc.sp_hash != sp); + psx_fp_record_mmio(&fp, 0x1F801070u, 1u, 0x80010008u); + assert(fp.mmio_count == 1 && fp.mmio_hash != seed.mmio_hash); + } + return 0; +}