Files
Sylpheed/docs/re/ui-splash-draw-pass.md
sylph-decoder c85e5b7c56 re: record that stale-instrument was run for the logger change, and returned nothing
R1 asks for tools/stale-instrument when an instrument improves. The draw
logger improved this iteration; all eight claims that <harness> killed are
about the SCREENSHOT harness -- polling cadence, x11grab latency, the
title/plate classifier -- and none of them ever used the draw logger, so
none re-open. Written down because 'ran it, nothing changed' and 'did not
run it' are otherwise the same absence.
2026-09-01 16:21:39 +00:00

273 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The splash draw pass — there is no post-process, and the fade is in the vertex stream
**Status: ✅ decoded (GPU state) for the pass structure; ✅ measured for the ramp.**
2026-09-01. Instrument: ⟨capture⟩ — the real game in Xenia Canary, per-draw, with
the render-target/resolve/constant fields added to the draw logger for this
question. Nothing here rests on a renderer of ours.
Asked by [`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md)
finding 4, in the order the human set: *is there a pass, what is it, where do its
parameters come from, and only then what curve.*
---
## The four answers, shortest first
1. **Is there a post-process pass at all? NO.** Not a blur, not a bloom, not a
fade quad over a resolved image, not a tone curve, not a resolve-and-resample.
One pass, one render target, one texture.
2. **What is it, then?** Per splash frame: a full-screen **replace** triangle
(the clear), a full-screen **black quad** through the normal blend, **one
batched sprite draw** carrying every visible splash element, and the two
presentation resolves. Three pixel shaders in total, all trivial.
3. **Where do the parameters come from?** **Not** the constant banks — the splash
pixel shaders read **zero** float constants, on all 1 048 draws. **Not**
immediates. The fade is the **per-vertex `k_8_8_8_8` colour**, in a vertex
buffer the guest rewrites every frame.
4. **The curve** falls out of 3 and is not fitted: alpha rises in integer steps
of **exactly 34/255 per presented frame**, clamped at 255.
**And the composite the game performs is ordinary source-over.** Read the shader
below before concluding otherwise from the blend register: the blend is
`ONE / ONE_MINUS_SRC_ALPHA`, which *looks* like a premultiplied pipeline, and it
is — because the **shader premultiplies**. The two together are algebraically
`src·α + dst·(1α)`. A renderer compositing these sprites with straight
alpha-over is using the right equation.
---
## 1 — the evidence that there is no second pass
[`data/splash-draw-pass-census.txt`](data/splash-draw-pass-census.txt), over all
**1 048 draws of frames 4…226**, which is both splashes end to end.
| what a post-process would show | what the census shows |
|---|---|
| a second render target | `rt0=[tile=0 fmt=0 exp=0]` on **1 048 / 1 048** draws — one EDRAM tile, one format, throughout |
| a reduced-resolution pass | `pitch=1280 msaa=0` on **1 048 / 1 048** |
| an extra pass' draws | `mode=` is only ever `4` (kColorDepth, 628) or `6` (kCopy, 420). Nothing else |
| resolve-and-resample | resolve destinations are only `0x14570000` and `0x14910000`, **210 each** — one pair per frame, the alternating front buffers. **No texture base anywhere in the capture equals a resolve destination** |
| a blur kernel sampling a target | the **only** texture bound in the whole splash region is `0x11A50000 1280×768 fmt=6`, the sprite page |
⚠️ **The trap this census is written to avoid.** Censusing the *whole* 600-frame
log finds six `640×360` textures and three `1280×720` ones, sampled ~330 times
each — which reads exactly like a half-resolution blur chain. They are the
attract **movie's** chroma and luma planes, triple-buffered, and they first
appear at frame 234, after both splashes are gone. Restricting to frames 4…226 is
what separates them; a census that did not would have "found" a post-process that
is not there.
The `tex[base=0x10000000 1×1 fmt=26]` on the resolve records is the previous
draw's binding still in the register file. A resolve does not sample it.
## 2 — what the pass actually is
Three pixel shaders, dumped from the running game with `--dump_shaders` and
committed at [`data/shaders/`](data/shaders/):
| ps hash | draws | blend | what it is |
|---|---|---|---|
| `0x2E372EA28CC404B7` | 210 (mode 4) | `0x00010001` = `ONE / ZERO` | the clear. A `prim=8 indices=3` triangle at **pixel** coordinates `(-0.5,-0.5)…(1279.5,719.5)` — the whole screen, written unconditionally |
| `0x5773DC18083C4C20` | 210 (mode 4) | `0x07010701` = `ONE / ONE_MINUS_SRC_ALPHA` | the backdrop. A four-vertex NDC quad `(-1,-1)…(1,1)`, vertex colour `FF000000` — opaque black, laid down through the blend rather than as a clear |
| `0xE59B2B3DA4AA9008` | 208 (mode 4) | `0x07010701` | **the splash sprites.** One draw per frame, batched: `indices=` 4, 8, 12 or 24, i.e. one to six quads in a single submission |
```
; shader_E59B2B3DA4AA9008.ucode.frag — the splash sprite shader, in full
tfetch2D r2, r1.xy, tf0 ; r2 = texture sample (straight alpha)
mul r1.___w, r2.wwww, r0.wwww ; A = tex.a * vcol.a
mul r0.xyz_, r2.xyzz, r0.xyzz ; rgb = tex.rgb * vcol.rgb
mul r1.xyz_, r0.xyzz, r1.wwww ; rgb = rgb * A <-- PREMULTIPLY
max oC0, r1, r1 ; oC0 = (rgb*A, A)
```
With `src=ONE, dst=ONE_MINUS_SRC_ALPHA` that composites to
```
dst' = tex.rgb·vcol.rgb·A + dst·(1 A), A = tex.a · vcol.a
```
which **is** source-over. The backdrop shader is the same premultiply with no
texture (`oC0 = (vcol.rgb·vcol.a, vcol.a)`).
📌 **So the "more pronounced fade/blur" the play-test reports is not a blend-mode
difference and not a post-process.** Both are now excluded by measurement. What
is left is the alpha values themselves and the set of quads submitted — and the
capture gives both, below.
## 3 — where the parameters come from
`ps_c[n=0]` on **1 048 / 1 048** splash draws. The pixel shaders declare **no
float constants at all** — this is read off each shader's own `float_bitmap`, the
same one the backend uploads from, so it is the shader's dependency set and not a
window that might have missed one.
That eliminates the constant banks. It also eliminates immediates in the command
stream: the only thing that differs between two consecutive splash draws is the
**vertex buffer**, and `vb=` is a different address every frame (`0x14D50550`,
`0x14D90790`, `0x14DB0950`, …) — a per-frame ring the guest writes into.
**The fade parameter is per-vertex colour, format `k_8_8_8_8` at attribute offset
16, in a vertex buffer rebuilt by guest code every frame.** Every vertex of a
given quad carries the same colour in every frame observed (the
`uniform_colour` column of the timeline is `True` throughout), so it is a
per-element scalar, not a gradient.
## 4 — the curve, which falls out of 3
[`data/splash-quad-timeline.txt`](data/splash-quad-timeline.txt) — every quad of
both splashes, per frame, straight off the vertex stream: NDC rect, alpha, rgb.
Eight distinct quads. Splash **A** (frames 4…123) is one logo plus one companion;
splash **B** (frames 127…226) is three logos plus three companions, the
companions being the same three rects scaled slightly larger.
| quad | NDC rect | frames | n |
|---|---|---|---|
| Q7 | x 0.530…+0.540, y 0.130…+0.120 | 4…14 | 8 |
| Q0 | x 0.520…+0.520, y 0.100…+0.080 | 7…123 | 111 |
| Q4 | x 0.410…+0.410, y +0.320…+0.570 | 127…147 | 21 |
| Q5 | x 0.200…+0.210, y 0.150…+0.150 | 127…147 | 21 |
| Q6 | x 0.320…+0.310, y 0.650…0.220 | 127…147 | 21 |
| Q1 | x 0.390…+0.390, y +0.350…+0.550 | 135…226 | 87 |
| Q2 | x 0.190…+0.190, y 0.120…+0.120 | 135…226 | 87 |
| Q3 | x 0.300…+0.300, y 0.620…0.250 | 135…226 | 87 |
**The ramp, splash B, on consecutive frames with no gaps in the run:**
```
Q4, Q5 17 51 85 119 153 187 221 255 steps: 34 34 34 34 34 34 34
Q6 17 51 85 119 153 187 221 254 steps: 34 34 34 34 34 34 33
Q1,Q2,Q3 34 68 102 136 170 204 238 255 steps: 34 34 34 34 34 34 17*
(*238+34 = 272, clamped to 255)
```
**Alpha steps by exactly 34 per presented frame**, on every one of the six quads,
until it clamps. 34 = 255/7.5, so a fade-in is **8 frames**, and it is an integer
recurrence, not a sampled continuous curve.
📌 **This number is not new and is not claimed as a finding.** It is exactly the
already-✅ law in [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) — *the
ramp is linear, the clock advances **2 time units per submitted frame**, and a
declared 15-unit fade lands on `round(255·k/15)`* — since 2 units/frame ×
(255/15 per unit) = **34 per frame**. What this capture adds is the **route**: the
34 is not applied by a shader, a constant or a tint register. The guest computes
it on the CPU and writes it into the vertex colour, and that is the only place it
exists on the GPU side. The two families' first samples differ (17 = one time
unit for Q4Q6, 34 = two for Q1Q3), so their clocks are offset by one unit.
⚠️ **This is stated in frames and quoted as counts and differences, deliberately.**
[`../agents/TEMPORAL-VERIFICATION.md`](../agents/TEMPORAL-VERIFICATION.md) — and
four withdrawn claims — say not to put a wall clock on this. I am **not**
converting 34/frame into 34/(1/60 s): whether a guest animation tick is a
presented frame is exactly the open Q1, and it is not settled by this capture.
What is settled is the **step size and the step count**, which have no phase.
The frame counter has gaps where the swap count advanced twice between two draw
batches — `(147,149) (152,154) (193,195) (217,219) (221,223)` for Q1Q3, and six
for Q0/Q7. **None of them falls inside a rising run**, which is why the step is
quotable at all; Q0's and Q7's rises *do* straddle gaps, so **splash A's step is
NOT established by this capture** and is not claimed here.
### Two things in the timeline that a fitted curve would never produce
* **Q6 leaves the plateau while Q4 and Q5 sit on it.** Q4/Q5 hold 255 for seven
frames; Q6 runs `254 249 243 237 232 226 220 214` over the same frames — its
own decay, about 5.6/frame, an order of magnitude gentler than the ±34 ramp.
Three structurally identical siblings are **not** interchangeable.
* **Q0's fade-out is the same anomaly this corpus already has open.** Its drops
are `1 5 12 22 34 33 33 34 33 33` — a 34/frame tail with an eased shoulder,
which is the shape `ui-keyframe-time-unit.md` records as `1, 11, 6, 22, 34, 33,
17, 33, 33, 17, 25, 8, 8` and cannot explain. Reproduced here on an independent
capture, so it is a property of the game and not of that run.
* **The companions lead the logos by eight frames and leave 79 frames early.**
Q4/Q5/Q6 run 127…147; Q1/Q2/Q3 run 135…226. They overlap for 13 frames and then
the companions are gone for the whole of the logos' hold.
## What this changes for the port
* Composite the splash sprites with **source-over**. The equation is confirmed
from the shader, not assumed.
* There is **no blur to add** and none to remove. If a screenshot comparison
wants more softness, that softness is in the **texture** or in **which quads are
drawn**, not in a pass.
* The alpha ramp is **integer, +34 per frame, clamped at 255** — not an eased
curve, and not the declared 80-unit fade-in (see the refutation below).
* Splash B draws **six** quads, not three. If an export drops the three
companions, the game will look softer than the port at exactly the moment the
play-test describes.
## Refutation attempt on another agent's claim — recorded per the adversarial duty
**Target:** `REFUTED.md`'s re-opened pair, *"`rest()` for a plateau-less element
should be the last keyframe"*, whose sibling argument says a rule which *"makes
`palogo_anima_eff` alone invisible while `gamearts_eff` and `seta_eff` stay lit"*
is wrong because the three are structurally identical. Both legs of that pair ran
through our renderer, which is why the register re-opened it, and its stated
settling condition is *"a draw capture of the developer splash naming which of the
three glows is submitted at rest."* This capture is that.
**Result: the premise SURVIVES in part and FAILS in part, and the failure is the
interesting half.**
***All three companions are submitted, in every frame they exist.** Q4, Q5 and
Q6 appear together in all 21 frames, 127…147. None is ever suppressed. A rule
that renders one of the three invisible does not describe this draw stream.
***But "structurally identical, therefore identical alpha" is false.** Q6
departs the plateau on its own decay while Q4 and Q5 hold 255. The one-byte
difference (`a=212` vs `a=255` at `t=45`) that the sibling argument treated as
noise **is drawn**. So the symmetry premise the argument rests on is refuted by
the oracle even though its conclusion about suppression happens to hold.
⚠️ **Reach.** This is one capture of the boot splashes, so it is `⟨capture⟩` and
generalises to *this* boot. The pass structure (§1, §2, §3) is a property of the
shaders and the register state and I would expect it to hold for every screen
that uses the same three shaders — but that is a prediction, not a measurement,
and the way to test it is the same census on the title and the menu.
## R1 housekeeping — `tools/stale-instrument` was run, and the answer is "none"
This iteration improved an instrument (the UI draw logger gained render-target,
resolve and pixel-shader-constant fields), so R1 requires asking what that
instrument had killed.
```
tools/stale-instrument harness # 8 claims
```
**None of the eight re-open by this change.** All eight are about the *screenshot*
harness — polling cadence, `x11grab` latency, the title/plate frame classifier,
the locale runs — and the draw logger is a different instrument that none of them
ever used. Recorded rather than left silent, because "I ran it and nothing
changed" and "I did not run it" are indistinguishable in a corpus otherwise.
The claims this capture *does* re-open are the four `render-vs-capture` and
`our-reader` entries around the splashes, and they are addressed in the
refutation section above rather than by the tool.
## What is NOT settled here
* **Splash A's step size** — its rising run straddles dropped frames.
* **Whether one presented frame is one guest animation tick.** Q1 is untouched.
* **Which disc field produces 34.** The ramp is measured in the vertex stream;
where the guest computes it is not decoded, and the +34 recurrence is not the
declared 80-unit fade-in — see `ui-keyframe-time-unit.md`, whose *"the declared
timeline reproduces the captured splash"* entry is 🟡 and is not re-derived by
this page.
* **The plate-late finding (play-test 3).** Not touched this iteration.
## How to reproduce
```bash
# 1. the logger (canary sylpheed-re d90d14e02, already built)
ln -sfn /canary /work/xenia-canary
cmake --build /sylph-home/re/canary-build --config Release --parallel 4 --target xenia_canary
rm /work/xenia-canary
# 2. the capture — GRACE=1 is what makes it catch the splashes at all
GRACE=1 NOTAP=1 ARM=early FRAMES=600 MAXDRAWS=400000 \
tools/re-capture/ui_draw_capture.sh 420 /sylph-home/re/splashdraw
# 3. the shaders
run-canary --dump_shaders=/some/dir # 60 s of boot is enough
```