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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,38 @@ cycle-accurate core later replaced.
an assumed `through_cycle + 1`. `checkpoint_diff` prints that window
open-ended rather than as `(0, N]`.

- **`<stem>.obs.bin`, the full-capture observable golden — because the CSV
cannot re-derive the checkpoints.** Found by trying to build the rung-0
self-diff on the CSV: `irq.csv` carries **23 columns** and neither `pc` nor
`put_cycle_post` is among them, so two of the nine observable fields are
simply absent from it. An external testbench reading the CSV therefore cannot
reproduce the checkpoint hashes, and "feed `RustyNES`'s golden back in as if
it were the DUT and get zero divergences" — the rung-0 gate — was not
implementable as designed.

The new golden is repeated 16-byte records in the **same wire encoding the
hash folds**, headerless. It is the only artifact the checkpoints can be
independently re-derived from, and it is also the input a re-run of a located
window consumes, so it would have been needed regardless. Additive: the CSV is
untouched, which matters because `scripts/irq_trace_cross_diff.py` and the
committed `golden/irq_trace/*.csv` both depend on its shape.

`Observable::decode` is the inverse and **refuses what it does not
understand** — a non-zero reserved pad byte, an undefined flag bit, an unknown
bus-access code, a short record, a stream length that is not a multiple of 16.
Reading a record from a newer producer as though nothing had changed is how a
*format* divergence gets reported as a *DUT* divergence. The stream is emitted
even when the checkpoints are refused for overflow: a hash over a truncated
trace claims a coverage it does not have, while the records themselves are
just records.

Measured across the repository boundary, not only in unit tests: **89,335
records** of AccuracyCoin, re-derived in C++ from `.obs.bin` alone, hashing to
byte-identical checkpoints — and a one-bit corruption at the halfway record
located to the 4096-cycle window containing it, in the same invocation,
because a positive control alone is satisfiable by a comparison that always
agrees.

### Fixed

- **The excluded crate's lockfile was silently gitignored, so CI re-resolved it
Expand Down
77 changes: 51 additions & 26 deletions crates/rustynes-cosim/src/bin/nes_golden_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//! | `<stem>.boot.bin` | `CpuBootTrace` binary | `cpu_boot_trace_diff` |
//! | `<stem>.irq.csv` | per-cycle IRQ/bus CSV | `scripts/irq_trace_cross_diff.py` |
//! | `<stem>.ckpt.bin` | rolling per-cycle hash checkpoints | `checkpoint_diff` |
//! | `<stem>.obs.bin` | full-capture observable stream, 16-byte records | the testbench's self-diff, and a window re-run |
//! | `<stem>.index_fb.bin` | 256x240 LE `u16` | the testbench's frame comparison |
//! | `<stem>.ram.bin` | 2 KiB CPU work RAM | `accuracy_coin_catalog::decode_results` |
//! | `<stem>.manifest.txt` | provenance | humans, and the drift guard below |
Expand Down Expand Up @@ -164,6 +165,48 @@ fn write(path: &Path, bytes: &[u8]) {
println!(" wrote {} ({} bytes)", path.display(), bytes.len());
}

/// Write the three artifacts derived from the per-cycle trace, and return the
/// counts the manifest records.
///
/// One take, three artifacts. `Bus::take_irq_trace` **moves** the trace out, so
/// asking for the CSV and then the checkpoints would silently yield an
/// unarmed-looking `None` for whichever came second -- and `None` there is
/// indistinguishable from "the trace was never armed".
fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, usize) {
let Some(a) = o.take_irq_artifacts(interval) else {
eprintln!(" WARNING: irq trace was armed but returned nothing");
return (0, 0);
};
write(&suffixed(base, "irq.csv"), a.csv.as_bytes());

// Written BEFORE the checkpoints, and outside the `Err` arm below, on
// purpose. This is the full-capture stream: it is the only artifact the
// checkpoint hashes can be independently re-derived from -- the CSV cannot,
// because it carries neither `pc` nor `put_cycle_post` -- and it is what a
// re-run of a located window consumes. An overflowed trace still holds real
// records, and those are worth keeping even when hashing them would claim a
// coverage they do not have.
let observable_count = a.observables.len();
write(
&suffixed(base, "obs.bin"),
&rustynes_cosim::checkpoint::observables_to_bytes(&a.observables),
);

match a.checkpoints {
Ok(ck) => {
write(
&suffixed(base, "ckpt.bin"),
&rustynes_cosim::checkpoint::to_bytes(&ck),
);
(ck.len(), observable_count)
}
// Refuse rather than emitting a short stream: a hash over a trace that
// dropped records covers fewer cycles than it claims, and the DUT would
// be blamed for our truncation.
Err(e) => panic!(" ERROR: {e}"),
}
}

fn main() {
let args = parse_args();
let rom =
Expand Down Expand Up @@ -228,31 +271,11 @@ fn main() {
None => eprintln!(" WARNING: boot trace was armed but returned nothing"),
}
}
// ONE take, two artifacts. `Bus::take_irq_trace` moves the trace out, so
// asking for the CSV and then the checkpoints would silently yield an
// unarmed-looking `None` for whichever came second.
let mut checkpoint_count = 0usize;
if args.irq_trace.is_some() {
match o.take_irq_artifacts(args.checkpoint_interval) {
Some(a) => {
write(&suffixed(&base, "irq.csv"), a.csv.as_bytes());
match a.checkpoints {
Ok(ck) => {
checkpoint_count = ck.len();
write(
&suffixed(&base, "ckpt.bin"),
&rustynes_cosim::checkpoint::to_bytes(&ck),
);
}
// Refuse rather than emitting a short stream: a hash over a
// trace that dropped records covers fewer cycles than it
// claims, and the DUT would be blamed for our truncation.
Err(e) => panic!(" ERROR: {e}"),
}
}
None => eprintln!(" WARNING: irq trace was armed but returned nothing"),
}
}
let (checkpoint_count, observable_count) = if args.irq_trace.is_some() {
write_irq_artifacts(&mut o, &base, args.checkpoint_interval)
} else {
(0, 0)
};

let manifest = format!(
"rom = {}\n\
Expand All @@ -266,7 +289,8 @@ fn main() {
index_fb_len = {}\n\
ram_len = {}\n\
ckpt_interval= {}\n\
ckpt_count = {}\n",
ckpt_count = {}\n\
obs_count = {}\n",
args.rom.display(),
sha256_hex(&rom),
args.seed,
Expand All @@ -279,6 +303,7 @@ fn main() {
RAM_LEN,
args.checkpoint_interval,
checkpoint_count,
observable_count,
);
write(&suffixed(&base, "manifest.txt"), manifest.as_bytes());
println!("done; {cycles} CPU cycles simulated");
Expand Down
182 changes: 182 additions & 0 deletions crates/rustynes-cosim/src/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,89 @@ impl Observable {
}
}

impl Observable {
/// Parse one record written by [`Self::encode`].
///
/// The inverse exists because the **golden `.irq.csv` cannot reconstruct an
/// `Observable`** — it has 23 columns and neither `pc` nor `put_cycle_post`
/// is among them. Without a decodable observable stream there is no way for
/// an external testbench to re-derive the checkpoint hashes from a golden,
/// which is exactly the rung-0 self-diff: feed `RustyNES`'s own output back
/// in as if it were the DUT and require zero divergences.
///
/// # Errors
///
/// If `bytes` is not exactly [`ENCODED_LEN`] long, or if the flag byte has
/// a bit set that this version does not define. The second check is not
/// pedantry: byte 15 is a reserved pad, and a producer that starts writing
/// something there is a producer this reader no longer understands. Failing
/// loudly beats silently ignoring a field that has come to mean something.
///
/// # Panics
///
/// Never in practice: the `expect` converts an 8-byte subslice of a slice
/// the `try_into` above has already fixed at 16 bytes. It is an `expect`
/// rather than a fallback so a future change to the record width fails
/// loudly instead of silently decoding garbage.
pub fn decode(bytes: &[u8]) -> Result<Self, &'static str> {
let b: [u8; ENCODED_LEN] = bytes
.try_into()
.map_err(|_| "observable record is not 16 bytes")?;
if b[14] & 0xF0 != 0 {
return Err("observable record has undefined flag bits set");
}
if b[15] != 0 {
return Err("observable record has a non-zero pad byte");
}
if b[13] > 4 {
return Err("observable record has an unknown bus-access code");
}
Ok(Self {
cpu_cycle: u64::from_le_bytes(b[0..8].try_into().expect("8 bytes")),
pc: u16::from_le_bytes([b[8], b[9]]),
bus_addr: u16::from_le_bytes([b[10], b[11]]),
bus_data: b[12],
bus_access: b[13],
put_cycle: b[14] & 1 != 0,
nmi_line: b[14] & 2 != 0,
irq_line_at_low: b[14] & 4 != 0,
irq_line_at_high: b[14] & 8 != 0,
})
}
}

/// Serialize an observable stream: repeated 16-byte [`Observable::encode`]
/// records, headerless.
///
/// This is the **full-capture** golden — the input a re-run of a located window
/// needs, and the only artifact from which the checkpoint hashes can be
/// independently re-derived.
#[must_use]
pub fn observables_to_bytes(records: &[Observable]) -> Vec<u8> {
let mut out = Vec::with_capacity(records.len() * ENCODED_LEN);
for r in records {
out.extend_from_slice(&r.encode());
}
out
}

/// Parse an observable stream written by [`observables_to_bytes`].
///
/// # Errors
///
/// If the length is not a multiple of [`ENCODED_LEN`], or if any record is
/// malformed. A trailing partial record means the producer was interrupted, and
/// a truncated stream that parses is a truncated comparison that passes.
pub fn observables_from_bytes(bytes: &[u8]) -> Result<Vec<Observable>, &'static str> {
if !bytes.len().is_multiple_of(ENCODED_LEN) {
return Err("observable stream length is not a multiple of 16 bytes");
}
bytes
.chunks_exact(ENCODED_LEN)
.map(Observable::decode)
.collect()
}

/// One emitted checkpoint: the hash of every cycle up to and including
/// `through_cycle`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -1138,6 +1221,105 @@ mod tests {
assert_eq!(first_full_capture_difference(&long, &long), None);
}

/// Every record must survive `encode` -> `decode` unchanged, or the
/// self-diff is comparing a lossy copy against the original and calling the
/// difference a DUT defect.
#[test]
fn every_observable_round_trips_through_the_wire_encoding() {
for cycle in 0..512u64 {
let o = obs(cycle);
let back = Observable::decode(&o.encode()).expect("round trip");
assert_eq!(o, back, "cycle {cycle} did not survive the round trip");
}
// Every bus-access code, including the DMA ones the CSV writer spells
// with lowercase letters.
for code in 0..=4u8 {
let mut o = obs(1);
o.bus_access = code;
assert_eq!(
Observable::decode(&o.encode()).expect("rt").bus_access,
code
);
}
// Every flag combination, so a bit-order slip cannot hide.
for bits in 0..16u8 {
let mut o = obs(1);
o.put_cycle = bits & 1 != 0;
o.nmi_line = bits & 2 != 0;
o.irq_line_at_low = bits & 4 != 0;
o.irq_line_at_high = bits & 8 != 0;
assert_eq!(
Observable::decode(&o.encode()).expect("rt"),
o,
"flags {bits:#06b}"
);
}
}

/// The decoder refuses what it does not understand rather than silently
/// ignoring it.
///
/// Byte 15 is a reserved pad and bits 4-7 of byte 14 are undefined. A
/// producer writing something there is a producer this reader no longer
/// understands, and reading its records as if nothing had changed is how a
/// format divergence becomes a DUT divergence.
#[test]
fn the_decoder_rejects_records_it_does_not_understand() {
let good = obs(7).encode();
assert!(Observable::decode(&good).is_ok());

let mut pad = good;
pad[15] = 1;
assert!(Observable::decode(&pad).is_err(), "non-zero pad accepted");

let mut flags = good;
flags[14] |= 0x10;
assert!(
Observable::decode(&flags).is_err(),
"undefined flag bit accepted"
);

let mut access = good;
access[13] = 5;
assert!(
Observable::decode(&access).is_err(),
"unknown access code accepted"
);

assert!(
Observable::decode(&good[..15]).is_err(),
"short record accepted"
);
assert!(
observables_from_bytes(&good[..15]).is_err(),
"truncated stream accepted"
);
}

/// **The rung-0 self-diff, in miniature.** Hashing the decoded stream must
/// reproduce the checkpoints hashed from the originals — otherwise an
/// external testbench reading `.obs.bin` gets different numbers from the
/// `.ckpt.bin` beside it, and the disagreement looks like a DUT defect.
#[test]
fn checkpoints_re_derived_from_the_observable_stream_match() {
let records: Vec<Observable> = (0..10_000).map(obs).collect();
let direct = hash_stream(&records, DEFAULT_INTERVAL);

let bytes = observables_to_bytes(&records);
assert_eq!(bytes.len(), records.len() * ENCODED_LEN);
let parsed = observables_from_bytes(&bytes).expect("parse");
assert_eq!(parsed, records, "the stream is not a faithful copy");

let re_derived = hash_stream(&parsed, DEFAULT_INTERVAL);
assert_eq!(
compare(&direct, &re_derived),
Comparison::Identical {
checkpoints: direct.len()
},
"checkpoints re-derived from the observable stream do not match"
);
}

#[test]
fn access_codes_are_stable() {
assert_eq!(Observable::access_code(true, false, false), 0);
Expand Down
Loading
Loading