debug_server: judge frame_fingerprint on guest-visible facts

frame_fingerprint's wr column is an ordered hash over main-RAM writes.
DMA and device writes (MDEC-out, CD, SPU, GPU->RAM) are recorded in host
device-service order. Native shards flush cycles at every store barrier,
while batched interpreted and static code services devices after the
block. So identical guest writes gave a different wr forever after.

- ws: an order-independent sum of a splitmix64 term per (addr, val) over
  main RAM. Together with wc it is the main-RAM judge column. wr and pc
  stay as ordered locators.
- qc: fingerprint-eligible writes that FMV-quiet kept out. If a straddled
  write falls into a quiet frame in one run only, the cumulative columns
  never agree again, and qc is where that shows.
- The accumulator moves to runtime/include/frame_fingerprint.h so the
  semantics are unit-tested (frame_fingerprint_test).
- The JSON buffer is sized from the real maximum entry length (284 B),
  which the old 224 B per entry could overflow.

docs/TCP_COMMANDS.md documents the column roles, the one-frame snapshot
straddle and the FMV-quiet caveat (set PSX_DEBUG_FMV_QUIET=0 for A/B
identity runs). Existing columns keep their meaning. Nothing outside
debug-tools builds changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-28 18:03:43 -07:00
parent 649aceba7b
commit 255d05e8e4
5 changed files with 338 additions and 75 deletions
+57 -2
View File
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | ✓ | ✓ |
+5
View File
@@ -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)
+113
View File
@@ -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 <stdint.h>
#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 */
+40 -73
View File
@@ -49,6 +49,7 @@
#include "gpu_gl_renderer.h"
#include "lockstep.h"
#include "guest_tty.h"
#include "frame_fingerprint.h"
#include <stdio.h>
#include <stdlib.h>
@@ -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,
+123
View File
@@ -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 <assert.h>
#include <stdint.h>
#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;
}