Files
Sylpheed/docs/port/port-frame-rate.md
Sylpheed port agent ba3f490c67 port: P7's gate has an artifact, and the GPU kills my finding-4 candidate outright
Three things this iteration, and the middle one is the human's doing.

P7 -- GATE MET, with an artifact. The path had been wired for some time
(then_video S00A, skipped_chain, after_video -> title, and S00A.ogv in the
export) and NOBODY HAD RUN IT. A milestone is done when its artifact exists, not
when the wiring reads correctly.

Pre-registered: unskipped, S00A is 93.78 s of media, so it should end on its own
at ~94 s and hand off to title. Measured: video ended at 94.13 s -- +0.35 s,
0.4 % -- then -> title, plate raised, script complete at 96.83 s. Both
predictions held. docs/port/p7-gate.md.

And it genuinely decodes, checked because "ends at the right time" does not prove
it: 45 frames in a 2.03 s window, 244 in an 18.91 s one. Scales with the window,
sub-linearly, at the software fill ceiling -- those two runs predate the GPU. The
counts are upper bounds and the port says so itself.

Free corroboration of the fill-rate finding, from a direction it was not designed
for: the unskipped run reports main_menu at 60.3 fps for 94 s, because the screen
never changes during a movie. Same process, same container, same screen: 60.3 fps
with one full-screen video texture on top, 9.7 fps drawing the menu's five
additive full-screen quads.

❌ THE FINDING-4 FRAME-RATE CANDIDATE IS DEAD, tested rather than argued away.
The human activated a hardware GPU in both containers; Godot takes it with no
change on our side (NVIDIA GTX 1070 Ti, Vulkan 1.4.312, Forward+):

  publisher_logo   17.3-25.0 -> 69.4 fps
  developer_logos  16.7-22.8 -> 69.1
  title            12.7-17.2 -> 61.1
  main_menu              9.7 -> 59.6

Pre-registered that 5-7 full-screen textured quads at 720p is trivial for that
card and the fade would get its full 45 steps. At 69 fps the 45-unit build-in
gets 52 drawn steps and the companion glow's 15-unit rise gets 17 -- MORE frames
than declared units, so every declared alpha is drawn and the quantisation is
absent rather than reduced.

So every candidate for finding 4 is now dead: keyframes vindicated against the
vertex stream, companion quads drawn, blend space matching, settled pose at
0.01 %, no post-process pass, and the frame rate drawing every declared step. The
port has nothing left that is KNOWN to be wrong about the splashes -- which is a
statement about our knowledge, not about the port. The human saw something and
nothing we can measure reproduces it. The next play-test is now the
highest-value thing on this focus, and the rate line will say 60-something.

One figure did not improve: main_menu's worst gap is 117 ms on the GPU against
150 ms on llvmpipe, essentially unchanged while the mean improved 6x. A hitch
that survives a 6x fill speed-up is not fill. Likely first-frame cost, STATED AS
UNTESTED, recorded rather than chased.

check-all now asserts the display before any Godot step. Xvfb does not survive a
container restart and LEAVES ITS SOCKET BEHIND, so Godot reports "X11 Display is
not available", falls back to Wayland, fails that too, and exits non-zero -- and
every Godot-backed step would have gone red for that one reason. Cost me one run
before I noticed. Checked with xdpyinfo rather than by looking for the socket,
because the stale socket is what makes it confusing.

Also recorded from the P7 run, neither chased: Godot reports 4 leaked ObjectDB
instances at exit on that path, and the menu bed keeps playing under the movie
(already an open ask -- does the menu music duck?).

Not settled: the 117 ms menu hitch; H6's +0x04 exposure; H1 (with the Decoder);
the four red verify-screen rows; and what the human actually saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AHUQvXGyNcKonSEWsgWcX
2026-09-01 18:32:48 +00:00

13 KiB
Raw Blame History

The port never reported its own frame rate — it does now, and it is 13–25 fps here

Status: ✅ instrument added and measured. ❌ The candidate it raised for play-test finding 4 is DEAD — tested on real hardware, not argued away. See the final section. The port's draw path never had a case to answer. Written 2026-09-01 by the Port at 977965e; HANDOFF on this branch answers 9ca1eb5.

The gap this closes

TEMPORAL-VERIFICATION.md §1 is unambiguous: a capture that asked for one rate and delivered another "is not a slow capture, it is a different capture", and an instrument that cannot report its own completeness may not be trusted.

That rule has been applied to --film (which I fixed for exactly this), to the Decoder's harnesses, and to the oracle. It had never once been applied to the thing being shipped. The port had no idea what rate it drew at and no way to say.

It matters here specifically, because the splashes are the current focus and the open complaint about them is that ours is less pronounced than the game's. A fade drawn in 45 frames and the same fade drawn in 12 are different animations, and nothing in this port could have told them apart.

The instrument

boot.gd now counts frames per screen and prints at every boot transition, at the end of the boot, and at every menu arrival:

     publisher_logo: 107 frames in 4.29 s -- 25.0 fps achieved, uncapped, worst gap 108 ms

worst gap sits beside the mean deliberately: a hitch is what reads as wrong. A screen averaging 55 fps with one 400 ms stall looks broken, and a mean hides that by construction.

🔴 Its first version printed -9223372036854775808 requested. DisplayServer.screen_get_refresh_rate() returns a float and is -1.0 when the display cannot say — which Xvfb cannot — and %d on that underflows to INT64_MIN. A rate line whose own denominator is nonsense is worse than no rate line. It now names the cap or says uncapped.

What it measures, in this container

Three boots, same command, nothing else running:

screen run 1 run 2 run 3 worst gap
publisher_logo 17.3 fps 19.6 25.0 100–115 ms
developer_logos 16.7 21.9 22.8 103–138 ms
title 17.2 14.2 12.7 150 ms, all three

13–25 fps, varying by ~2× run to run, with hitches of 100–150 ms. The title's 150 ms is identical across all three runs, which looks like a one-off cost rather than load — the video player is torn down immediately before it.

🔴 And the menu is worse than any of them

The report was boot-only on its first version and said so nowhere — the boot walks through _advance, while --menu arrives through _menu_arrive. So the mode a human actually spends time in, and the one where a slow frame is felt as input lag rather than seen as a coarse fade, reported nothing. An instrument covering half the application while its own page claims "every boot" is exactly the shape this port keeps finding in other people's work. Fixed in the same commit:

     main_menu: 20 frames in 2.05 s -- 9.7 fps achieved, uncapped, worst gap 150 ms

9.7 fps. The main menu is the heaviest screen in the port — five additive elements, five spinning focus rings, a full-screen background — and it is the one the play-test spent its time on. At 9.7 fps a press takes up to 103 ms to appear and a spinning ring advances in ~10 visible steps per revolution.

⚠️ This is llvmpipe software rasterisation under Xvfb in a loaded container. It is not a measurement of the human's hardware and must not be quoted as one. What it establishes is that the port can run this slowly and never said so.

The consequence, and why it is a live candidate for finding 4

The port's timeline is driven by time_units += delta * units_per_second, so the durations stay correct at any frame rate — the fade still takes 0.75 s. What changes is how many distinct alphas that fade is drawn at:

rendered at steps in the 45-unit build-in steps in the glow's 15-unit rise
16.7 fps (measured) 12.5 4.2
22.8 fps (measured) 17.1 5.7
30 Hz 22.5 7.5
60 Hz 45.0 15.0

The pre-blurred companion glow — the thing that is the splash's blur — rises over 15 units. In this container it is drawn at four to six distinct alphas. At 60 Hz it would be fifteen.

A soft crossfade rendered in four steps, inside a 750 ms animation carrying a 100–150 ms hitch, is a plausible mechanism for "close, but not quite right" and for "the game's is more pronounced" — and it is the first candidate for finding 4 that is not already dead. Every other one is: the keyframes are vindicated against the vertex stream, the companion quads are drawn, the blend space matches, the settled pose scores 0.01 % against the capture, and there is no post-process pass to add.

🔴 It is a candidate, not a cause. It depends entirely on what the machine running the port manages, and I cannot measure the human's. The line now prints on every boot, so the next play-test answers it for free: if it says 60 fps and the splash still looks wrong, this is dead too.

What is NOT affected, and it was worth checking

Every timing result this port has published stands. They are all derived from _elapsed, which is += delta — a sum of frame times, correct at any rate — and from time_units, which is the same sum scaled. So:

  • the splash dwells (4.270 s and 3.527 s) are unaffected — measured across runs whose frame rates differed by 2×, and they agreed to ±0.03 s;
  • the plate's arrival, the shared-clock check and the film's own index are all _elapsed-based.

That is the reassuring half of the same design: the port is correct at 13 fps and merely coarse. Had the timeline been frame-counted, every number in this corpus would have been wrong by a factor that changed between runs — which is precisely the failure the Decoder found in the emulator's own rate and withdrew a finding over.

What this does not claim

  • That 13–25 fps is what a player sees. It is what this container manages.
  • That the port has a performance defect. Textures are cached at load_screen and not decoded per frame — that was checked and is not the cause.
  • That capping or vsyncing would help. It would not raise the rate here, and changing presentation behaviour on my own authority is not mine to do.

The control: it is the software rasteriser, and the port has no case to answer

Written the iteration after the section above, because "is 9.7 fps llvmpipe or something in our draw path" was left open and it is not a question to leave open after publishing a candidate cause.

Pre-registered (R2)

If the rasteriser is the limit, a near-empty Godot scene in this same container will also run at roughly 10–25 fps. If my draw path is the limit, it will run far faster — take >100 fps as the discriminator.

Same container, same Xvfb, same 1280×720 viewport, same [rendering] settings, almost nothing drawn:

FPSPROBE mode=empty: 651 frames in 4.03 s -- 161.6 fps, worst gap 54 ms

161.6 fps. The engine loop, the viewport and the present path are not the limit, by a factor of sixteen over the menu.

🔴 And the first control was not a control

Its fill modes drew untextured draw_rects while every element the port draws is a texture. A control that does not do what its subject does bounds nothing — and it showed: the port's splashes were achieving ~21 Mpx/s against that control's ~50, which read as the port being mysteriously slow and was really the control being mysteriously fast. Adding a matched textured mode:

mode full-screen quads achieved
empty 0 161.6 fps
fill3 3, untextured 31.5
tex3 3, textured 23.9
fill7 7, untextured 12.7
tex7 7, textured 11.2

The port sits inside the bracket, ordered by large-quad count

screen full-screen-ish quads measured bracket
publisher_logo 1 17.3–25.0 ≈ tex3
developer_logos 1 16.7–22.8 ≈ tex3
title 6 12.7–17.2 between tex3 and tex7
main_menu 5 9.7 ≈ tex7 (11.2)

Every screen lands between the two matched controls, in the order the quad count predicts. The port is drawing large textured alpha quads on a software rasteriser at exactly the rate a software rasteriser draws large textured alpha quads.

⚠️ Note the control's own spread: fill3 measured 18.0 on one run and 31.5 on another. The container's load swings by ~1.75×, so the absolute numbers here are noise-dominated and only the ordering and the bracket are load-invariant — which is what TEMPORAL-VERIFICATION.md §3 says to prefer, and the reason this conclusion rests on those rather than on any single figure.

🔴 So the finding-4 candidate is downgraded, and I am saying so plainly

The section above called the frame rate "the first candidate for finding 4 that is not already dead". That now looks wrong, and it was mine.

The quantisation argument still holds — a fade drawn in four steps is not a fade drawn in fifteen — but it only bites at these frame rates, and these frame rates are a property of software rasterisation in this container. Five to seven full-screen quads at 720p is nothing to any GPU; on real hardware the port would hit vsync and the fade would get its full 45 steps.

So unless the human ran the port software-rendered, this is not what they saw, and every candidate for play-test finding 4 is now dead or near-dead. That is an honest dead end rather than a lead, and it is written as one: leaving a plausible-sounding cause standing when its own control has undercut it is exactly how "close but not right" got explained four different wrong ways.

The one thing that would revive it costs the human nothing: the rate line now prints on every boot. If their next run says 60 fps, this is finished.

What the port does NOT need

  • No draw-path optimisation. 161.6 fps empty says the loop is fine, and the screens sit on the fill curve exactly where their quad counts put them.
  • No texture caching work — already done at load_screen, checked last iteration.
  • No max_fps or vsync change. It would not raise the rate here, and it is a presentation decision rather than a defect.

❌ Settled on hardware: 60–69 fps, and the candidate is dead

The human activated a hardware GPU in both containers on 2026-09-01, which made the one open half of this page directly testable. Godot picks it up with no change on our side:

Vulkan 1.4.312 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce GTX 1070 Ti

Pre-registered (R2)

Five to seven full-screen textured quads at 720p is trivial for that card. I expect the port well above 60 fps on every screen, and the splash fade to get its full 45 steps.

screen llvmpipe (before) GPU (after) worst gap
publisher_logo 17.3–25.0 69.4 83 ms
developer_logos 16.7–22.8 69.1 15 ms
title 12.7–17.2 61.1 67 ms
main_menu 9.7 59.6 117 ms

A 3–6× jump, and every screen is now at or above 60 fps. The prediction held.

What that does to the fade

animation declared steps drawn at 69 fps
splash build-in, 45 units = 0.750 s 45 alphas 52
companion glow's rise, 15 units = 0.250 s 15 alphas 17

More frames than declared units, so every declared alpha is drawn. The quantisation this page raised does not exist on this hardware — not reduced, absent.

❌ So the candidate is dead, and it died the right way

It was raised as a mechanism, downgraded by a matched control, and is now closed by a direct measurement on the hardware in question. Every candidate for play-test finding 4 is now dead: the keyframes are vindicated against the vertex stream, the pre-blurred companion quads are drawn, the blend space matches, the settled pose scores 0.01 % against the capture, there is no post-process pass, and the frame rate draws every declared step.

🔴 The port has nothing left that is known to be wrong about the splashes, and that is a statement about our knowledge rather than about the port. The human saw something. Nothing we can measure reproduces it.

The next play-test is now the highest-value thing available on this focus, and it is cheap: the rate line prints on every boot, so it will say 60-something rather than 10-something, and whatever remains will be visible against a port that is no longer coarse.

The one figure that did not improve

main_menu's worst gap is 117 ms on the GPU against 150 ms on llvmpipe — essentially unchanged while the mean improved 6×. A hitch that survives a 6× fill speed-up is not fill. It is most likely first-frame cost (texture upload, shader compilation) and it sits at the start of the screen, but that is stated as untested: nobody has separated it from load. It is small, it is once per screen, and it is recorded rather than chased.