diff --git a/tools/canary-patches/0001-RE-Log-the-blend-state-of-every-UI-draw.patch b/tools/canary-patches/0001-RE-Log-the-blend-state-of-every-UI-draw.patch new file mode 100644 index 00000000..e1fe4dae --- /dev/null +++ b/tools/canary-patches/0001-RE-Log-the-blend-state-of-every-UI-draw.patch @@ -0,0 +1,58 @@ +From 0f920e645441f42d006a3d2635115ff9bcfb37b8 Mon Sep 17 00:00:00 2001 +From: Sylpheed RE agent +Date: Mon, 31 Aug 2026 06:09:25 +0000 +Subject: [PATCH 1/4] [RE] Log the blend state of every UI draw + +CaptureUiDrawForRE now records RB_BLENDCONTROL0, RB_COLORCONTROL and +RB_COLOR_MASK per draw, raw alongside the decoded src/op/dst fields so a decode +bug here cannot quietly become the answer. + +The question it answers: the Godot port composites every UI element with +straight alpha-over and four elements come out too dark against the capture, +with the shortfall correlating with the background. Nothing on the disc selects +a per-element mode, so this reads what the GPU was actually told. Result: the +title-side UI uses two states and one pixel shader -- 0x07010701 (src ONE, dst +1-SRC_ALPHA) for backgrounds, text and buttons, and 0x01010101 (src ONE, dst +ONE, ADDITIVE) for the frame sprites and the rotated sweep strips. +--- + src/xenia/gpu/command_processor.cc | 27 +++++++++++++++++++++++++++ + 1 file changed, 27 insertions(+) + +diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc +index e350f1132..d389e299c 100644 +--- a/src/xenia/gpu/command_processor.cc ++++ b/src/xenia/gpu/command_processor.cc +@@ -296,6 +296,33 @@ void CommandProcessor::CaptureUiDrawForRE( + if (ps) { + ui_out << fmt::format(" ps=0x{:016X}", ps->ucode_data_hash()); + } ++ // ── RE: the BLEND STATE of this draw ────────────────────────────────────── ++ // The question this answers: the port's renderer composites every UI element ++ // with straight alpha-over, and four elements (the menu's `ptframe1`/`2` and ++ // EXTRAS' `ptframe3`/`4`) come out too dark against the capture, with the ++ // shortfall correlating with the BACKGROUND rather than with the element's own ++ // contribution — the signature of a blend that scales what is already there. ++ // Nothing on the disc selects a per-element mode (checked in the declaration ++ // entry, every word and bit of the T8aD header, and the keyframe record), so ++ // the remaining candidate is the draw path. This logs what the GPU was ++ // actually told, per draw, rather than inferring it: ++ // blend= src/dst factors and combine op, colour+alpha ++ // cc= alpha test / blend enable ++ // mask= ++ // Raw values are printed alongside the decoded fields so a decode bug here ++ // cannot silently become the answer. ++ { ++ auto bc = register_file_->Get(); ++ auto cc = register_file_->Get(); ++ uint32_t mask = register_file_->values[XE_GPU_REG_RB_COLOR_MASK]; ++ ui_out << fmt::format( ++ " blend=0x{:08X}[c:src={} op={} dst={} a:src={} op={} dst={}]" ++ " cc=0x{:08X} mask=0x{:X}", ++ bc.value, uint32_t(bc.color_srcblend), uint32_t(bc.color_comb_fcn), ++ uint32_t(bc.color_destblend), uint32_t(bc.alpha_srcblend), ++ uint32_t(bc.alpha_comb_fcn), uint32_t(bc.alpha_destblend), cc.value, ++ mask); ++ } + if (ps && ps->is_ucode_analyzed()) { + for (const auto& tb : ps->texture_bindings()) { + xenos::xe_gpu_texture_fetch_t tf = diff --git a/tools/canary-patches/0002-RE-Raise-the-UI-draw-capture-s-vertex-dump-from-8-to.patch b/tools/canary-patches/0002-RE-Raise-the-UI-draw-capture-s-vertex-dump-from-8-to.patch new file mode 100644 index 00000000..6223f5a7 --- /dev/null +++ b/tools/canary-patches/0002-RE-Raise-the-UI-draw-capture-s-vertex-dump-from-8-to.patch @@ -0,0 +1,36 @@ +From fa1e4c22147b2d9138f43a946559e9512d1e2a36 Mon Sep 17 00:00:00 2001 +From: Sylpheed RE agent +Date: Mon, 31 Aug 2026 07:00:16 +0000 +Subject: [PATCH 2/4] [RE] Raise the UI draw capture's vertex dump from 8 to 64 + +8 vertices is TWO QUADS. A batched UI draw carries more: on Project Sylpheed's +EXTRAS screen one 24-index draw holds six sprites, and truncating at 8 reported +the first two while ptframe4, pteff21, pteff22 and pteff23 looked like elements +the game never draws at all. A cap that hides geometry is worse than a long +line, because the missing rows do not announce themselves. +--- + src/xenia/gpu/command_processor.cc | 10 ++++++++-- + 1 file changed, 8 insertions(+), 2 deletions(-) + +diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc +index d389e299c..20e75c6ae 100644 +--- a/src/xenia/gpu/command_processor.cc ++++ b/src/xenia/gpu/command_processor.cc +@@ -374,9 +374,15 @@ void CommandProcessor::CaptureUiDrawForRE( + return (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) | + (uint32_t(q[2]) << 8) | uint32_t(q[3]); + }; ++ // 🔴 This was 8, and 8 is TWO QUADS. A batched UI draw carries more: on ++ // `EXTRAS` one 24-index draw holds six sprites, and truncating at 8 ++ // reported the first two and silently dropped `ptframe4`, `pteff21`, ++ // `pteff22` and `pteff23` — which then looked like elements the game does ++ // not draw at all. A cap that hides geometry is worse than a long line, ++ // because the missing rows do not announce themselves. + uint32_t nv = uint32_t(init.num_indices); +- if (nv > 8) { +- nv = 8; ++ if (nv > 64) { ++ nv = 64; + } + ui_out << fmt::format(" fmt0={} v:", pos_fmt); + for (uint32_t v = 0; v < nv; ++v) { diff --git a/tools/canary-patches/0003-RE-Log-render-target-state-resolves-and-PS-constants.patch b/tools/canary-patches/0003-RE-Log-render-target-state-resolves-and-PS-constants.patch new file mode 100644 index 00000000..0a7efaa3 --- /dev/null +++ b/tools/canary-patches/0003-RE-Log-render-target-state-resolves-and-PS-constants.patch @@ -0,0 +1,147 @@ +From d90d14e0210d87d24618aba1e870eabafbe0c262 Mon Sep 17 00:00:00 2001 +From: Sylpheed RE agent +Date: Tue, 1 Sep 2026 16:13:43 +0000 +Subject: [PATCH 3/4] [RE] Log render-target state, resolves and PS constants + on every UI draw + +The UI draw log recorded blend state, textures and geometry, which is +enough to say WHICH sprite a draw is and how it composites, and not +enough to answer whether a screen has more than one PASS. A second draw +into an off-screen target, or a resolve of the EDRAM into a texture that +a later full-screen quad samples, both look like just another quad in +the old format. + +Three additions, all read straight out of the register file: + + mode= RB_MODECONTROL.edram_mode. kCopy (6) is how this GPU issues + a RESOLVE, and it arrives through the same DRAW_INDX packet + as a sprite -- so without this field a resolve was + indistinguishable from a draw. + rt0=/pitch RB_COLOR_INFO's EDRAM tile and format, RB_SURFACE_INFO's + pitch and MSAA. A second target shows up as a different + tile; a half-resolution post-process shows up as a pitch + that is not the screen's. + RESOLVE on a kCopy draw, RB_COPY_CONTROL and RB_COPY_DEST_BASE. + That destination reappearing as a later tex[base=...] is + what 'resolve-and-resample' means stated in addresses, + rather than inferred from the picture. + ps_c[...] the pixel shader's float constants, taken off its OWN + float_bitmap -- the same one the backend uploads from -- so + this is the shader's declared dependency set rather than a + fixed window that could miss the one that matters. Pixel + constants live at SHADER_CONSTANT_256_X and the c# printed + is the index the shader's disassembly uses. + +Without the constants, an alpha ramp driven by a shader constant and one +driven by per-vertex colour are the same picture. +--- + src/xenia/gpu/command_processor.cc | 82 ++++++++++++++++++++++++++++++ + 1 file changed, 82 insertions(+) + +diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc +index 20e75c6ae..25ada855a 100644 +--- a/src/xenia/gpu/command_processor.cc ++++ b/src/xenia/gpu/command_processor.cc +@@ -18,6 +18,7 @@ + #include "xenia/base/cvar.h" + #include "xenia/base/logging.h" + #include "xenia/base/profiling.h" ++#include "xenia/base/math.h" + #include "xenia/gpu/gpu_flags.h" + #include "xenia/gpu/graphics_system.h" + #include "xenia/gpu/packet_disassembler.h" +@@ -323,6 +324,49 @@ void CommandProcessor::CaptureUiDrawForRE( + uint32_t(bc.alpha_comb_fcn), uint32_t(bc.alpha_destblend), cc.value, + mask); + } ++ // ── RE: the RENDER TARGET this draw goes to, and whether it is a RESOLVE ── ++ // The question this answers: "is there a post-process pass at all?" is a ++ // question about render targets and passes, and the log above cannot see one. ++ // Two screens' worth of splash draws were read off a stream that recorded only ++ // blend state and geometry, so a second pass into an off-screen target — or a ++ // resolve of the EDRAM into a texture that a later full-screen quad samples — ++ // would have been invisible: it looks like just another quad. ++ // ++ // mode= RB_MODECONTROL.edram_mode. 0 = colour+depth (a normal draw), ++ // 4 = kCopy, which on this GPU is how a RESOLVE is issued — it ++ // arrives through the same DRAW_INDX packet as everything else, ++ // so without this field a resolve is indistinguishable from a ++ // sprite. ++ // rt0= RB_COLOR_INFO: the EDRAM tile the draw writes to, its format, ++ // and the exponent bias. A second pass into a DIFFERENT edram ++ // base is the signature of an off-screen target. ++ // pitch/msaa RB_SURFACE_INFO. A post-process at reduced resolution shows up ++ // here as a pitch that is not the screen's. ++ // RESOLVE … on a kCopy draw, where the EDRAM is being copied TO. That ++ // destination address reappearing as a `tex[base=…]` on a later ++ // draw is what "resolve-and-resample" means, stated in addresses ++ // rather than inferred from the picture. ++ { ++ auto mc = register_file_->Get(); ++ auto si = register_file_->Get(); ++ auto ci = register_file_->Get(); ++ ui_out << fmt::format( ++ " mode={} pitch={} msaa={} rt0=[tile={} fmt={} exp={}]", ++ uint32_t(mc.edram_mode), uint32_t(si.surface_pitch), ++ uint32_t(si.msaa_samples), ++ uint32_t(ci.color_base) | (uint32_t(ci.color_base_bit_11) << 11), ++ uint32_t(ci.color_format), int32_t(ci.color_exp_bias)); ++ if (mc.edram_mode == xenos::EdramMode::kCopy) { ++ auto cpc = register_file_->Get(); ++ ui_out << fmt::format( ++ " RESOLVE copy=0x{:08X}[src={} samp={} cmd={} cclr={} dclr={}]" ++ " dest=0x{:08X}", ++ cpc.value, uint32_t(cpc.copy_src_select), ++ uint32_t(cpc.copy_sample_select), uint32_t(cpc.copy_command), ++ uint32_t(cpc.color_clear_enable), uint32_t(cpc.depth_clear_enable), ++ register_file_->values[XE_GPU_REG_RB_COPY_DEST_BASE]); ++ } ++ } + if (ps && ps->is_ucode_analyzed()) { + for (const auto& tb : ps->texture_bindings()) { + xenos::xe_gpu_texture_fetch_t tf = +@@ -335,6 +379,44 @@ void CommandProcessor::CaptureUiDrawForRE( + uint32_t(tf.format)); + } + } ++ // ── RE: the PIXEL SHADER's float constants, as the shader itself indexes ── ++ // "Where do a pass's parameters come from?" has four candidate answers — ++ // immediates in the command stream, the constant banks, a table in a pak, or ++ // a ramp computed in guest code. This log could not previously distinguish ++ // any of them, because it recorded no constants at all: an alpha ramp driven ++ // by a PS constant and one driven by per-vertex colour look identical once ++ // you are reading pixels. ++ // ++ // Only the constants the shader ACTUALLY READS are printed, off its own ++ // `float_bitmap` — the same bitmap the backend uses to upload them — so this ++ // is the shader's declared dependency set rather than a fixed window that ++ // might miss the one that matters or bury it in 200 unused vectors. Pixel ++ // constants live in the second half of the file (SHADER_CONSTANT_256_X), and ++ // the c# printed is the index the shader's own disassembly uses. ++ if (ps && ps->is_ucode_analyzed()) { ++ const auto& crm = ps->constant_register_map(); ++ ui_out << fmt::format("\n ps_c[n={}{}]:", crm.float_count, ++ crm.float_dynamic_addressing ? " DYNAMIC" : ""); ++ uint32_t printed = 0; ++ for (uint32_t w = 0; w < 4 && printed < 24; ++w) { ++ uint64_t bits = crm.float_bitmap[w]; ++ uint32_t b; ++ while (printed < 24 && xe::bit_scan_forward(bits, &b)) { ++ bits = xe::clear_lowest_bit(bits); ++ uint32_t idx = (w << 6) + b; ++ uint32_t r = XE_GPU_REG_SHADER_CONSTANT_256_X + (idx << 2); ++ ui_out << fmt::format(" c{}=({:.5f},{:.5f},{:.5f},{:.5f})", idx, ++ register_file_->Get(r), ++ register_file_->Get(r + 1), ++ register_file_->Get(r + 2), ++ register_file_->Get(r + 3)); ++ ++printed; ++ } ++ } ++ if (crm.float_count > printed) { ++ ui_out << fmt::format(" …+{} more", crm.float_count - printed); ++ } ++ } + // The quad's geometry is the only thing that says WHICH element a draw is: + // these sprites all share one shader and sample big texture pages, so the + // vertex data is the identity. Dump attribute 0 of binding 0 for the first diff --git a/tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch b/tools/canary-patches/0004-RE-log-a-CONTENT-hash-beside-every-sampled-texture.patch similarity index 98% rename from tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch rename to tools/canary-patches/0004-RE-log-a-CONTENT-hash-beside-every-sampled-texture.patch index 4e4998a2..96aec238 100644 --- a/tools/canary-patches/0001-content-hash-in-ui-draw-logger.patch +++ b/tools/canary-patches/0004-RE-log-a-CONTENT-hash-beside-every-sampled-texture.patch @@ -1,7 +1,7 @@ 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 +Subject: [PATCH 4/4] 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 @@ -93,6 +93,3 @@ index 25ada855a..c53ce3194 100644 } } // ── 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 index 5e466c79..022b1fce 100644 --- a/tools/canary-patches/README.md +++ b/tools/canary-patches/README.md @@ -1,24 +1,51 @@ -# Canary patches the corpus depends on +# The Canary commits that exist only in this container -`/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. +`/canary` is a separate checkout on branch `sylpheed-re`. **Four commits on it are +not on any remote**, and several committed findings cannot be reproduced without +them. This directory is their durable copy, because `/work` is pushed and +`/canary` is not. -Apply with `git -C /canary am < `, then rebuild: +Bounded by measurement, not by guess: `git -C /canary branch -r --contains` puts +`590912722` on `origin/sylpheed-re` and finds no remote for any commit after it. +So the container-only stack is exactly these four. + +| patch | canary sha | what it adds | findings that need it | +|---|---|---|---| +| `0001` | `0f920e645` | **`blend=` — `RB_BLENDCONTROL0` per draw** | [`ui-blend-mode-decoded.md`](../../docs/re/structures/ui-blend-mode-decoded.md) — the entire blend decode, and the additive path in `ui_layout` that follows from it | +| `0002` | `fa1e4c221` | vertex dump raised 8 → 64 | the `EXTRAS` 24-index batch: at 8 vertices the log printed two quads of six and **silently dropped four**, which is why `ptframe4`/`pteff21`/`22`/`23` looked absent | +| `0003` | `d90d14e02` | render-target state, resolves, PS constants | [`ui-splash-draw-pass.md`](../../docs/re/ui-splash-draw-pass.md) — "is there a post-process pass at all?" is a question about render targets, and the log could not see one | +| `0004` | `ab3203f79` | **`h=` — a content hash per sampled texture** | [`guest-frame-rate-resolved.md`](../../docs/re/guest-frame-rate-resolved.md), its withdrawal, and [`clock-is-frame-based-one-unit-per-present.md`](../../docs/re/clock-is-frame-based-one-unit-per-present.md) | + +## Applying ```bash -ln -sfn /canary /work/xenia-canary # the build needs this symlink present +git -C /canary am /work/tools/canary-patches/*.patch +ln -sfn /canary /work/xenia-canary # the build REQUIRES this symlink 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) | +⚠️ Without the symlink the build fails with `The source directory +"/work/xenia-canary" does not exist` from a `cmake --regenerate-during-build` +step, which does not obviously point at a missing symlink. -⚠️ 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. +⚠️ `--parallel 2`, not 4: this container has been OOM-killed before. + +## Why this directory exists + +**A finding whose reproduce recipe names a `/canary` sha is not reproducible.** +Three corpus pages cite `canary sylpheed-re d90d14e02` as though it were a public +reference; it is reachable from nowhere but this box. The failure is silent — the +recipe *looks* complete, and only fails for someone who tries it on a different +machine, long after the person who wrote it could explain what the flag did. + +📌 The sharpest case is `0001`. Without it a draw log records no blend state at +all, so `ui-blend-mode-decoded.md`'s 35-element oracle — the thing that overturned +a REFUTED entry and deleted the port's authored blend map — could not be re-derived +by anyone who cloned this repository. + +📌 And `0004` is the one whose absence actively misleads rather than merely +blocking: without a content hash a draw log records only texture *base addresses*, +and a triple buffer rotating once per present is indistinguishable from one decode +per present. That confusion cost two withdrawn positions on `units/second` in a +single day.