Files
Sylpheed/docs/re/ui-splash-draw-pass.md
sylph-decoder 4bb67fa166 re: the splashes have NO post-process -- one pass, source-over, alpha in the vertex stream
Play-test finding 4 asked, in order: is there a pass, what is it, where do
its parameters come from, only then what curve. All four, from GPU state.

1. NO post-process pass. Over all 1 048 draws of frames 4..226 (both boot
   splashes): rt0=[tile=0 fmt=0 exp=0] on 1048/1048, pitch=1280 msaa=0 on
   1048/1048, edram_mode only ever kColorDepth or kCopy, resolve dests only
   the two alternating front buffers, and NO texture base anywhere in the
   capture equals a resolve destination. The only texture bound in the whole
   splash region is the sprite page 0x11A50000. No blur, no bloom, no fade
   quad over a resolved image, no tone curve, no resolve-and-resample.

2. What it is: per frame, a full-screen replace triangle (the clear), a
   full-screen black quad through the ordinary blend, ONE batched sprite
   draw carrying every visible element (indices 4/8/12/24), and the two
   presentation resolves.

3. Where the parameters come from: NOT the constant banks -- the splash
   pixel shaders read zero float constants, ps_c[n=0] on 1048/1048, taken
   off each shader's own float_bitmap. 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 the corpus's existing 34/frame law,
   reproduced on an independent capture. Not claimed as new.

And the composite is ORDINARY SOURCE-OVER, confirmed from the shader ucode
rather than inferred from the blend register: the register reads
ONE/ONE_MINUS_SRC_ALPHA, which looks premultiplied, and is -- because the
shader premultiplies. The two together are src*A + dst*(1-A). So the 'more
pronounced' fade is neither a blend difference nor a pass.

The likely mechanism instead: the developer splash submits SIX quads, three
logos plus three slightly-larger companions that lead them by 8 frames and
are gone 79 frames early. Two over-blended copies of the same art at
different scale is a halo, on screen only during the entry.

Refutation attempt, recorded: REFUTED.md's re-opened rest() pair states its
settling condition as 'a draw capture of the developer splash naming which
of the three glows is submitted at rest'. This is that capture. All three
companions ARE submitted in all 21 frames -- so no rule that hides one
describes this stream -- but they are NOT interchangeable: Q6 leaves the
plateau on its own -5.6/frame decay while Q4 and Q5 hold 255. The one-byte
sibling difference is drawn.

Trap recorded with the evidence: censusing the whole 600-frame log finds six
640x360 textures sampled ~330 times each, which reads exactly like a
half-resolution blur chain. They are the attract movie's chroma planes and
first appear at frame 234, after both splashes. Restricting the window is
what separates them.

Evidence: docs/re/data/splash-draw-pass-census.txt,
docs/re/data/splash-quad-timeline.txt, docs/re/data/shaders/*.ucode.frag.
Logger: canary sylpheed-re d90d14e02.
2026-09-01 16:21:17 +00:00

14 KiB
Raw Blame History

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 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, 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/:

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 — 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.mdthe 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 — 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.

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

# 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