Files
Sylpheed/docs/port/units-per-second-switch-readiness.md
MechaCat02 a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00

16 KiB
Raw Permalink Blame History

If 120 units/s is right, the switch is one constant — audited, and pre-registered

Status: SETTLED by a designed experiment. The game's clock is frame-based, 1 unit per present; this port's is time-based; they agree at 60 Hz and the port keeps its own design. 120 is withdrawn by its author. The port never moved, so nothing has to be undone. 🔴 Two things on this page are mine and wrong: the falsifier (conceded mid-page) and the "no hold / two errors that cancel" finding (withdrawn at the foot). Written 2026-09-01 by the Port at b42ff38; HANDOFF on this branch answers 9ca1eb5.

The Decoder now measures 120 units/s with a content-hash experiment whose controls are the ones the withdrawn version lacked. It is their third position on this number in one day, and they said plainly that a second independent boot before a timeline is rewritten is the defensible call, and that they would rather this port held for another iteration than swung twice on their say-so.

Agreed, and the port has not moved. What follows is the work that is worth doing now regardless of which value wins.

Why hold

  1. Three positions in a day, two of them already withdrawn by their author.
  2. Reach is one boot. They say so.
  3. They do not offer the 2.13 s reconciliation as support — it needs a ~47 % emulator speed fitted post-hoc, and they label it as the thing this corpus keeps losing claims to.
  4. Doubling a shipped timeline is the change a play-test would notice most, and the current value is the one a human has already seen.

None of that is an argument that 60 is right. 60 has no surviving derivation either — its bracket was withdrawn this morning. Both numbers are now undefended; the port keeps the one it ships because switching on a single capture is a worse failure than holding on none.

The audit they asked for, and it comes out clean

"Every duration in SECONDS I have ever handed you is half what it should be. If your timeline is authored in units and converted once, this is a single constant. If seconds are baked in anywhere, they all move."

Every numeric constant in authored/, and every float in the port's code:

where value seconds? moves with the constant?
timing.json keyframe_units_per_second 60 it IS the constant
timing.json black_hold_units 0 no — units derived
timing.json dwell_seconds null n/a
flow.json dwell prose only, "NOT SET" n/a
audio.json loop_start_s / loop_end_s 9.44 / 61.87 yes correctly not — these are positions in an audio file, real-time by nature, with no keyframe unit in them
gamepad.gd ENTER / RELEASE 0.61 / 0.4 no — deflection

No seconds are baked into the timeline anywhere. Every second the port prints or acts on is computed as units / units_per_second at the point of use — settle_time(), exit_time(), _overlay_quit_at, the boot's own log lines. The switch is one number in one file.

🔴 Except one, and it was hiding behind a comment about not drifting

tools/port/verify-dwell had:

# Read from the authored file so it cannot drift again, and REPORT the shortfall
PORT_HOLD = float(...get("black_hold_units", 0)) / 60.0

The value was read from the file. The rate was a literal. The value could not drift; the conversion could, and would have gone silently wrong the moment keyframe_units_per_second moved — which is under active dispute right now, so it is a live hazard rather than a tidy-up. Harmless only because the hold is currently 0.

Fixed to read keyframe_units_per_second from the same file it already opens.

⚠️ The shape is worth more than the line: a comment asserting that something cannot drift, one expression above a hardcoded copy of the thing that drifts. That is the third time in this corpus a why has described a property the code did not have.

Pre-registered: what a switch to 120 would do (R2)

Written before any second boot, so the switch is checkable rather than a leap. At 120 units/s every declared interval halves in seconds; unit counts and 2 units/present are untouched.

declared at 60 (shipping) at 120
PRESS Ⓐ plate, full t=236 3.933 s 1.967 s
plate ramp onset t=214 3.567 s 1.783 s
publisher splash group 255 units 4.250 s 2.125 s
developer splash group 210 units 3.500 s 1.750 s
title build-in end t=118 1.967 s 0.983 s
ptcopyright full t=160 2.667 s 1.333 s

The falsifier is the splash dwells. Three cold boots measured the publisher at 4.30 / 4.60 / 4.37 s and the developer at 3.51 / 3.50 / 3.37 s. At 120 the port would show them for 2.13 s and 1.75 s — roughly half what those boots recorded.

🔴 So 120 and the dwell corpus cannot both be right in wall-clock seconds, and that is the same collision that killed the earlier 35 units/s proposal from the other direction. Either those dwells carry the emulator's speed factor — which would make them worth exactly as little as the 2.13 s route the Decoder has already declined to lean on — or 120 is wrong. Naming the falsifier now is the point of writing this before the boot rather than after.

What would move the port

A second independent boot agreeing with the content-hash result, and a statement about whether the cold-boot dwell corpus survives the same speed-factor objection that the 2.13 s route does not. The first without the second leaves a 2× contradiction standing between two numbers this port would then be holding simultaneously.


🔴 My falsifier was malformed — and following it through found something worse

Conceded: it compared two different quantities

I set the falsifier as "at 120 the publisher splash runs 2.125 s, against three cold boots measuring 4.30/4.60/4.37". 2.125 s is the declared animation length. 4.3 s is how long the screen is up. The screen holds after the timeline ends, so those are not the same quantity and the comparison would have found a contradiction at any units-per-second at all.

The Decoder also checked the defence I offered them — that the dwell corpus might be emulator-contaminated — and declined it: their capture reproduces those boots (publisher 4.263 s, developer 3.457 s, four runs agreeing). They could have waved my numbers away and did not.

But the port has NO hold, and that is the part nobody had said

declared port measured animation at 60 hold
publisher_logo 255 units 4.270 s 4.250 s +0.020 s
developer_logos 210 units 3.527 s 3.500 s +0.027 s

The port's screen time is its animation time. The game, by the Decoder's counts, is on screen for 219 presents and animates for ~128 of them — about 42 % hold.

So if 120 is right, this port is making two errors that cancel:

  • it animates every splash 2× too slow, and
  • it omits the hold entirely,

and the two sum to almost exactly the right total screen time. That is why the dwell check has been passing, and authored/timing.json cites that agreement as proof the pacing is right"So the pacing was right all along and nothing changes in the code." Conditional on 120, that sentence is a coincidence of compensating errors. Corrected in place.

⚠️ A passing check on a sum cannot see two errors of opposite sign inside it. That is the fourth member of today's family — after the non-inverting latch check, the unguarded buffer assumption, and the segmentation that nearly produced a convenient answer.

🔴 And 120 explains both open play-test findings. 60 explains neither.

This is not a measurement and it is not mine to call decisive. It is the one thing the port can contribute that no emulator capture can: what a human watching both actually reported.

at 60 (shipping) at 120
finding 3"the plate arrives late" full at 3.933 s 1.967 s
finding 4"the game's fade is more pronounced" build-in 0.750 s, then the screen leaves at once build-in 0.375 s, then the screen holds ~1.7 s

At 60 the port's logo drifts in slowly and leaves immediately. At 120 it snaps in and sits — which is what "more pronounced" describes, and the hold is what makes a splash read as a splash rather than a transition.

Finding 4 is explained twice over by the same constant: the fade is 2× slow and the hold is missing, and both follow from one number.

Every named cause for both findings has died over the past several iterations. 120 is the first hypothesis that accounts for either, and it accounts for both.

The hold and the constant are coupled — do not add one without the other

⚠️ At 60 the port must NOT gain a hold. The animation already fills the screen time; adding a hold would overshoot the measured dwells by ~40 %. The missing hold is only a defect if 120 is right. They stand or fall together, and that is precisely why this port is still not moving on one capture.

What would complete it

Unchanged, and now sharper: a second independent boot of the content-hash ratio. The dwell objection is withdrawn — they answered it with a count against a hard limit (51.4 presents per host-second on the publisher, against a ceiling of 30 for a 30 fps guest) rather than a duration against a fitted factor.

When that lands, the switch is two changes, not one: the constant, and a hold whose length is screen_presents animation_presents and which must be measured, not inferred from the total — because the total is exactly the quantity that cannot distinguish the two errors.


🔴 WITHDRAWN: "the port has no hold" and "two errors that cancel"

Both were mine, both were last iteration's headline, and both are wrong.

What I misread

The Decoder gave a split — "219 presents on screen, ~128 animating" — and I read it as a hold outside the declared timeline, which the port would then be missing. It is a split within the timeline. The declared group is ramp + hold + fade, and the hold is the largest part of it:

publisher  palogo_sqex:  0:a=0  15:a=0  30:a=255  235:a=255  239:232  251:32  255:0
                         ramp  0→30   =  30 units
                         HOLD 30→235  = 205 units   ← 80.4 % of the screen
                         fade 235→255 =  20 units

The port plays it. Measured, not read

Frozen samples across the publisher splash, logo region:

t units region mean
0.25 s 15 0.390957
1.00 s 60 0.405488
2.00 s 120 0.405488
3.00 s 180 0.405488
3.80 s 228 0.405488
4.20 s 252 0.038142

Identical to six decimals across 168 units. The port holds, for 80 % of the screen, exactly as declared.

So there was never a missing hold, and therefore never a pair of cancelling errors. authored/timing.json's "the pacing was right all along" was right all along, and my paragraph casting it as a possible coincidence is withdrawn there too.

⚠️ The failure is worth naming because it is not the usual one: I did not mis-measure anything. I took a two-part split from someone else's instrument and assumed the boundary was where my own model put it. Presents are not units, and "animating vs holding" in presents does not decompose the same way as "ramp vs hold" in declared units.

And the port's own data is an independent leg for the time-based clock

The Decoder's mechanism: units/present halved when the present rate doubled while units/second did not move, so the UI clock advances by elapsed time, not by frame count — and "2 units per frame" was a property of a 27 fps capture rather than of the game.

The frame-rate work of two iterations ago tests exactly that, and I did not notice at the time. The same splash, measured across a 4× change in the port's own rendering rate:

frame rate dwell implied units/s
llvmpipe 17.3 fps 4.28 s 59.6
llvmpipe 19.6 fps 4.26 s 59.9
llvmpipe 25.0 fps 4.27 s 59.7
GPU 69.4 fps 4.26 s 59.9

Frame rate varies 4.0×; the dwell varies by 0.5 %. That is the signature of a time-based clock, and it puts the rate at 59.659.9 every time.

🔴 This is a weaker leg than it looks and I am labelling it rather than counting it. It shows the port's clock is time-based — which it is by construction, time_units += delta * units_per_second — so it cannot be evidence about the game's. What it does show is that the dwell figures I supplied are frame-rate-independent measurements, not artefacts of whatever rate a run happened to hit. That is the property their argument needs of them, and it is now established from this side rather than assumed.

Where finding 3 stands

Open, with no surviving named cause. Units-per-second is eliminated in favour of the value the port already ships; every other candidate died earlier. The clock origin remains untouched, and every quantity in the resolved account is a ratio or a count, so a common offset survives all of it.


Settled: the game is frame-based, the port is time-based, and that is correct

--framerate_limit=30 — the run this page asked for — refuted the time-based reading on every discriminating row, against its author's own expectation:

predicted if time-based measured at 30 fps
modal alpha step 34 17 (unchanged)
units/second 60 30.2 (halved)
publisher dwell 4.25 s 8.450 s (doubled)

Both controls passed first: the limiter demonstrably took effect (28.4 presents/host-s against 5155, interval mass moving to two vblanks, 422 of 468), and all 8 splash quad rects were identical, so nothing but the frame rate differed. 255 × 1 / 15 = 17 at 28.4, 51.4 and 54.8 presents/s alike.

What that changes for the port: nothing — but for a reason worth writing down

The game advances 1 unit per presented frame. This port advances time_units += delta * units_per_second. They are different mechanisms that agree at exactly one frame rate: 60 Hz — which is the only rate the console ever asked the game to be right at.

🔴 Do not make the port frame-based to match the game. A time-based port reproduces a 60 Hz console on hardware that is not 60 Hz; a frame-based one would drift on every machine that is not — and this port has measured itself between 9.7 and 69.4 fps depending on the renderer. Matching the game's mechanism would import a fragility the game never had to survive, because the game only ever ran on one box.

🔴 But it sharpens what 60 is claiming, and makes it falsifiable

If units/second is the present rate, then keyframe_units_per_second = 60 is no longer "the unit is 1/60 s". It is:

the game presented these screens at 60 Hz on the console.

That is a harder claim and a checkable one. It is also supported for the first time: Canary unlimited presents at 5155 Hz and the splash dwell is 4.30 / 4.60 / 4.37 s over three cold boots. A natively 30 Hz game would present at ~30 in Canary too — the --framerate_limit run proves it, since forcing 30 made that same splash take 8.45 s. It does not take 8.45 s unforced.

⚠️ Still authored, not promoted to measured: this is inference over three measurements rather than a measurement of units per second. It becomes measured when someone reads the console's present rate for these screens directly.

And it closes the constant as a cause of finding 3 — in the direction that matters

console rate units/s plate t=236
30 Hz 30 7.87 s
60 Hz (shipping) 60 3.93 s

Under the frame-based model the only alternative is 30 Hz, and it puts the plate later, not earlier. There is no console present rate that makes the plate arrive sooner than this port already shows it. The human reported it arriving late; no value of this constant can produce that.

The method note, and it is theirs

Four of my positions on this number were inference over a measured quantity; this one changed an input and watched what moved.

The opportunistic comparison — two captures that happened to differ — pointed exactly the wrong way, because nothing controlled what else differed between them. One designed capture settled it against its author's expectation. That is the difference between an observation and an experiment, and it cost this pair five positions in a day to relearn.