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.
273 lines
15 KiB
Markdown
273 lines
15 KiB
Markdown
# 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 Q4–Q6, 34 = two for Q1–Q3), 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 Q1–Q3, 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
|
||
```
|