Sylpheed renders the splash (draws=78, iterate-2O) then plateaus: the
title's per-frame manager (sub_821741C8) only re-fires when "clock B"
([gfx+15160], swap count) changes, which only the CP swap-complete
callback sub_824CE2B8 increments. The graphics ISR sub_824BE9A0
indirect-calls that callback via [[gfx+10772]+16] on CP (source=1)
interrupts, but the slot stayed NULL so the callback never ran.
Root (runtime-verified, ours-side GPU): the guest arms the slot through
the Xenos CP scratch-register writeback path, which ours never
implemented. The arming IB (drained by ours at 0x4adf5180) contains a
Type-0 register write of the callback PC 0x824ce2b8 into SCRATCH_REG4
(0x057C). On hardware/canary, writing a SCRATCH_REG{n} mirrors the value
to SCRATCH_ADDR + n*4 in memory when the matching SCRATCH_UMSK bit is
set. Runtime values: SCRATCH_ADDR=0x0b1d5000 (the [gfx+10772]
descriptor), SCRATCH_UMSK=0x20033 (bit 4 set), so SCRATCH_REG4 ->
0x0b1d5010 = descriptor+16 = the callback slot (0x4b1d5010). Ours
decoded the Type-0 write into the register file but performed no
writeback (case a: drained-but-mishandled), so the slot stayed NULL.
Fix mirrors canary's CommandProcessor::HandleSpecialRegisterWrite
(command_processor.cc:545-552): a scratch_register_writeback() helper
called from handle_type0/handle_type1 after every register write; for
SCRATCH_REG0..7 with the UMSK bit set, it writes the value (big-endian,
as mem.write_u32 already stores) to SCRATCH_ADDR + n*4 (projected via
physical_to_backing). Deterministic given identical register state;
proven by unit test.
Cascade (verified by runtime probe): slot 0x4b1d5010 now armed with
0x824ce2b8; on the 2-3 CP interrupts that fire, the ISR reads the slot
and bcctrl's into sub_824CE2B8 (runs 2x; 0x cascade on master);
sub_824CE2B8 increments clock B ([gfx+15160]). The cascade does NOT yet
reach draws>78: there are only ~3 CP interrupts (from the initial 9825-
packet batch), and the title render loop stalls upstream (the iterate-2Q
title-respawn gate) before it submits more PM4_INTERRUPT work, so the
callback can't bootstrap a self-sustaining loop. This is the remaining
update-17/18 arming gap closed; the upstream stall is the next gate.
The default threaded GPU backend drains the ring on a separate host
thread, so with the callback now doing work the exact CP-interrupt
delivery instruction varies run to run (pre-existing GPU-thread race).
Pin the n50m oracle test to --gpu-inline (instruction-count
deterministic) and re-baseline its golden; bit-exact across repeated
runs. New unit test scratch_reg_write_mirrors_to_memory_when_umsk_enabled.
Tests: 675 pass (was 674). Golden re-baselined + determinism verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
96 lines
4.0 KiB
Rust
96 lines
4.0 KiB
Rust
//! Sylpheed boot-sequence regression oracles.
|
|
//!
|
|
//! These goldens trigger `xenia-rs check` against the Project Sylpheed ISO and
|
|
//! compare the resulting digest to a checked-in JSON file via `--stable-digest`,
|
|
//! which excludes timing-sensitive counters (`packets`, `interrupts_*`,
|
|
//! `resolves`, `texture_decodes`). The remaining fields are deterministic in
|
|
//! lockstep at a fixed instruction budget — verified empirically across 3
|
|
//! consecutive runs.
|
|
//!
|
|
//! Goldens are CIRCULAR per ORACBUG-001/002/003: they were captured by running
|
|
//! the same code they validate. Treat them as **regression anchors** (catch
|
|
//! drift from a known-good snapshot) not **correctness anchors** (no claim
|
|
//! about absolute behavior). When a planned fix intentionally moves the
|
|
//! digest (e.g. swap fix → `swaps` increments; renderer fix → `draws` becomes
|
|
//! non-zero), re-baseline the golden as a separate commit.
|
|
//!
|
|
//! Tests are `#[ignore]`-gated because the runs take ~4 seconds each, which
|
|
//! is unacceptable for the default `cargo test` cycle. Run explicitly:
|
|
//! cargo test --release -p xenia-app --test sylpheed_oracles -- --ignored --nocapture
|
|
//!
|
|
//! ISO path is read from the `SYLPHEED_ISO` env var, falling back to the
|
|
//! repo-relative default. CI/contributors without the ISO will see the test
|
|
//! skip gracefully.
|
|
|
|
use std::process::Command;
|
|
|
|
const ISO_DEFAULT: &str = "/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso";
|
|
|
|
fn iso_path() -> String {
|
|
std::env::var("SYLPHEED_ISO").unwrap_or_else(|_| ISO_DEFAULT.to_string())
|
|
}
|
|
|
|
fn run_oracle(label: &str, max_instr: u64, golden_rel: &str) {
|
|
let bin = env!("CARGO_BIN_EXE_xenia-rs");
|
|
let iso = iso_path();
|
|
if !std::path::Path::new(&iso).exists() {
|
|
eprintln!("{label}: iso not found at {iso}; set SYLPHEED_ISO to override. SKIPPING.");
|
|
return;
|
|
}
|
|
|
|
// Resolve the golden path relative to the test's CARGO_MANIFEST_DIR so the
|
|
// test runs correctly from any cwd.
|
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
|
let golden = std::path::Path::new(manifest_dir).join(golden_rel);
|
|
assert!(
|
|
golden.exists(),
|
|
"{label}: golden file missing at {}",
|
|
golden.display()
|
|
);
|
|
|
|
let max_instr_str = max_instr.to_string();
|
|
let golden_str = golden.to_string_lossy().to_string();
|
|
|
|
let out = Command::new(bin)
|
|
.args([
|
|
"check",
|
|
&iso,
|
|
"-n",
|
|
&max_instr_str,
|
|
// Pin the inline (single-threaded) GPU backend. The default
|
|
// threaded backend drains the ring on a separate host thread,
|
|
// so the exact instruction at which a CP interrupt is queued —
|
|
// and therefore when the guest's swap-complete ISR callback runs
|
|
// (iterate-2S armed it via SCRATCH_REG writeback) — varies run to
|
|
// run. Inline draining is instruction-count-deterministic, which
|
|
// is what a regression golden needs. (The threaded path is the
|
|
// documented "GPU thread race" the stable-digest already warns
|
|
// about.)
|
|
"--gpu-inline",
|
|
"--stable-digest",
|
|
"--expect",
|
|
&golden_str,
|
|
])
|
|
.output()
|
|
.expect("failed to spawn xenia-rs");
|
|
|
|
if !out.status.success() {
|
|
eprintln!(
|
|
"{label}: STDOUT:\n{}\nSTDERR:\n{}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
panic!("{label}: digest mismatch (exit {:?})", out.status.code());
|
|
}
|
|
}
|
|
|
|
/// Sylpheed boot to first VdSwap pair, captured at -n 50M lockstep.
|
|
/// Catches regressions in: addi/addic semantics, kernel HLE for VdSwap path,
|
|
/// thread spawning, file I/O for sound/config. With Phase A's swap fix landed,
|
|
/// `swaps` should be 2 and `draws` 0 (Phase E gates draws>0).
|
|
#[test]
|
|
#[ignore = "long-running; run via `cargo test ... -- --ignored sylpheed_n50m`"]
|
|
fn sylpheed_n50m() {
|
|
run_oracle("sylpheed_n50m", 50_000_000, "tests/golden/sylpheed_n50m.json");
|
|
}
|