From afec2ae592d81e028fee15e4dfe620ad98d02d59 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Tue, 1 Sep 2026 19:56:29 +0000 Subject: [PATCH] tools: export the Canary logger patch the clock findings depend on /canary is a separate checkout that lives only in the container and is pushed nowhere. The content-hash field in the UI draw logger is what separates "the buffer rotated" from "a frame was decoded", and two committed findings -- guest-frame-rate-resolved.md and clock-is-frame-based-one-unit-per-present.md -- cannot be reproduced without it. Committed in /canary as ab3203f79 on branch sylpheed-re for a nameable sha, and exported here as a patch because that sha is not reachable from anywhere outside this box. Includes the rebuild recipe, since the build needs the /work/xenia-canary symlink present and that is not obvious from the failure it gives without it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t --- .../0001-content-hash-in-ui-draw-logger.patch | 98 +++++++++++++++++++ tools/canary-patches/README.md | 24 +++++ 2 files changed, 122 insertions(+) create mode 100644 tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch create mode 100644 tools/canary-patches/README.md diff --git a/tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch b/tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch new file mode 100644 index 00000000..4e4998a2 --- /dev/null +++ b/tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch @@ -0,0 +1,98 @@ +From ab3203f7926d155fdeaf0c8f7616cf0a6d8a43b6 Mon Sep 17 00:00:00 2001 +From: Sylpheed RE agent +Date: Tue, 1 Sep 2026 19:56:12 +0000 +Subject: [PATCH] RE: log a CONTENT hash beside every sampled texture + +A base address cannot distinguish 'the guest decoded a new frame' from 'the +guest rotated to the next buffer of a triple-buffered set' -- a rotating +buffer visits the same three addresses either way. Reading a clean +one-base-change-per-present as one decode per present is how +docs/re/guest-frame-rate-measured.md reached a conclusion it had to +withdraw. + +h=, omitted rather than faked when the +address does not translate, so a missing hash cannot read as a matching +one. Sampled rather than full: a 1280x720 plane is 900 KB and hashing all +of it per draw would change the thing being measured. +--- + src/xenia/gpu/command_processor.cc | 57 ++++++++++++++++++++++++++---- + 1 file changed, 51 insertions(+), 6 deletions(-) + +diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc +index 25ada855a..c53ce3194 100644 +--- a/src/xenia/gpu/command_processor.cc ++++ b/src/xenia/gpu/command_processor.cc +@@ -277,7 +277,21 @@ void CommandProcessor::CaptureUiDrawForRE( + return; + } + if (frame != ui_last_frame) { +- ui_out << fmt::format("--- frame {} ---\n", frame); ++ // ── RE: GUEST time on the frame boundary, not host time ───────────────── ++ // The question this exists for: the animation clock's rate. A step in a ++ // sprite's alpha per FRAME is not a rate — under this emulator the frame ++ // rate is whatever the host can manage — and a host wall-clock duration is ++ // the instrument that has already cost this corpus four withdrawn claims. ++ // ++ // `Clock::QueryGuestTickCount()` is the timebase the GUEST reads, at ++ // `guest_tick_frequency()` (set to 50 MHz in emulator.cc) with the guest ++ // time scalar applied. So a duration computed from these two numbers is the ++ // duration the GAME experienced, which is the only one its own integrator ++ // could have used. Printing the frequency beside the count means the reader ++ // does not have to know what it was set to. ++ ui_out << fmt::format("--- frame {} gtick={} gfreq={} ---\n", frame, ++ Clock::QueryGuestTickCount(), ++ Clock::guest_tick_frequency()); + ui_last_frame = frame; + } + +@@ -372,11 +386,42 @@ void CommandProcessor::CaptureUiDrawForRE( + xenos::xe_gpu_texture_fetch_t tf = + register_file_->GetTextureFetch(tb.fetch_constant); + // Dimensions are stored as (actual - 1). +- ui_out << fmt::format(" tex[base=0x{:08X} {}x{} fmt={}]", +- uint32_t(tf.base_address) << 12, +- uint32_t(tf.size_2d.width) + 1, +- uint32_t(tf.size_2d.height) + 1, +- uint32_t(tf.format)); ++ // ── RE: a CONTENT hash of the sampled texture, not just its address ── ++ // The question this answers: "did the guest DECODE a new frame, or did it ++ // merely ROTATE to the next buffer of a triple-buffered set?" A base ++ // address cannot tell those apart — a rotating buffer visits the same three ++ // addresses whether or not anything was written into them — and reading a ++ // clean 1-base-change-per-present as "one decode per present" is exactly ++ // how `guest-frame-rate-measured.md` reached a conclusion it had to ++ // withdraw. Identical content on consecutive presents means rotation ++ // without decode; changing content means a genuine decode. ++ // ++ // Sampled, not full: a 1280x720 plane is 900 KB and hashing all of it per ++ // draw would change the thing being measured. 4096 bytes spread across the ++ // whole allocation is plenty to separate "identical" from "different" and ++ // costs nothing. `h=` is omitted rather than faked when the address does ++ // not translate, so a missing hash can never be read as a matching one. ++ uint32_t tbase = uint32_t(tf.base_address) << 12; ++ uint32_t tw = uint32_t(tf.size_2d.width) + 1; ++ uint32_t th = uint32_t(tf.size_2d.height) + 1; ++ ui_out << fmt::format(" tex[base=0x{:08X} {}x{} fmt={}", ++ tbase, tw, th, uint32_t(tf.format)); ++ if (tw > 1 && th > 1) { ++ const uint8_t* tp = ++ memory_->TranslatePhysical(tbase); ++ if (tp) { ++ // FNV-1a over a fixed stride so the sample set is deterministic and ++ // does not depend on the format's true bytes-per-pixel. ++ uint64_t hsh = 1469598103934665603ull; ++ uint32_t span = tw * th; // >= 1 byte per texel for every format here ++ uint32_t step = std::max(1u, span / 4096u); ++ for (uint32_t o = 0; o < span; o += step) { ++ hsh = (hsh ^ tp[o]) * 1099511628211ull; ++ } ++ ui_out << fmt::format(" h={:016X}", hsh); ++ } ++ } ++ ui_out << "]"; + } + } + // ── RE: the PIXEL SHADER's float constants, as the shader itself indexes ── +-- +2.43.0 + diff --git a/tools/canary-patches/README.md b/tools/canary-patches/README.md new file mode 100644 index 00000000..5e466c79 --- /dev/null +++ b/tools/canary-patches/README.md @@ -0,0 +1,24 @@ +# Canary patches the corpus depends on + +`/canary` is a **separate checkout that lives only in the container** and is not +pushed anywhere. A finding whose reproduce recipe needs a modified logger is not +reproducible if that modification exists only there — so any Canary change a +committed finding rests on is exported here as a patch. + +Apply with `git -C /canary am < `, then rebuild: + +```bash +ln -sfn /canary /work/xenia-canary # the build needs this symlink present +cmake --build /sylph-home/re/canary-build --config Release --parallel 2 --target xenia_canary +rm /work/xenia-canary +``` + +| patch | canary sha | what needs it | +|---|---|---| +| `0001-content-hash-in-ui-draw-logger.patch` | `ab3203f79` on branch `sylpheed-re` | [`../../docs/re/guest-frame-rate-resolved.md`](../../docs/re/guest-frame-rate-resolved.md) and its withdrawal, [`../../docs/re/clock-is-frame-based-one-unit-per-present.md`](../../docs/re/clock-is-frame-based-one-unit-per-present.md) | + +⚠️ The `h=` field this adds is what separates *"the buffer rotated"* from *"a +frame was decoded"*. Without it a draw log records only base addresses, and a +triple buffer rotating once per present looks identical to one decode per +present — the confusion that cost this corpus two withdrawn positions on +`units/second` in a single day.