port: P7 -- the new-game intro plays, and the two screens it skips are named out loud

S00A has been exported since P4; what P7 needed was something to play it and a
defined place to land. Both are here, and the interesting part is the gap.

The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot ->
~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations that are not
GP_TITLE builds, so no screen file exists to go to. The port jumps from NEW GAME
to the one thing in that chain it has -- and the whole design is about not
letting that read as a sequence:

  * MenuFlow.accept returns a new kind, `video`, rather than folding this into
    `blocked`, because the caller has to announce the skip and a distinct kind is
    what forces it to;
  * the runtime prints the skipped screens by name on every run;
  * flow.json carries `skipped_chain` as DATA, so what is missing lives beside
    the decision instead of inside a GDScript string.

After the movie the port returns to the title. Authored, and it has to be: the
game goes into mission 1 and gameplay is out of scope. The ~4.5 s before the
movie is left EMPTY on purpose -- GP_TITLE does carry a loading screen and 4.5 s
is about the right shape for one, which is exactly why that belongs in BLOCKED.md
and not in flow.json.

`--script`'s 20 s per-step timeout would have killed every movie run at step 1.
Raising the constant would have been wrong the other way: a movie stuck at frame
0 would then hang the job, and a job that waits is worse than one that fails. The
test is now LIVENESS -- while get_stream_position() advances the deadline moves
with it, and a stalled movie still trips the same 20 s.

Found while looking: GP_TITLE's four unnamed builds (entries 0, 1, 12, 15) are
LOADING screens -- every element in all four is pgloading_*, and LOADING is one of
the three names the decoder read out of the title part's state function. NOT
renamed here: which member of each pair is which locale is an inference, and a
name stops being questioned once written. Handed over.

One of them is a second casualty of the rest.t problem, and a worse one:
pgloading_eff00.prm rests OPAQUE BLACK at t=38, so anything drawing that screen
at its declared rest paints a black rectangle over all of it. The title's case
only dimmed a frame.

REFUTATION, attempted and SURVIVED: HANDOFF says "exactly the six screen builds
carry the black .prm quad while the six overlays do not". Counting bundles with a
full-screen black primitive gives 8 and 4 -- build_12/15 carry one too. But
theirs runs black -> held -> clear where the transition quad runs black -> clear
-> black, so read strictly as "the quad whose group is the transition" the claim
holds. Recorded anyway: there are two kinds, and the naive census over-counts.

Gate: NEW GAME -> S00A plays 93.75 s against a declared 93.9 -> title, with
98.453 s recorded off the Master bus. What that does NOT show is that S00A's own
audio is in the mix -- bed and movie were not separated in this run, and the
write-up says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
This commit is contained in:
Sylpheed port agent
2026-08-29 13:12:41 +00:00
parent 59fba56a0f
commit d45da73bba
5 changed files with 255 additions and 1 deletions

View File

@@ -110,7 +110,22 @@
"goto": null,
"goto_name": "DIFFICULTY",
"goto_name_kind": "name match, not measured",
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet."
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
"skipped_chain": [
"DIFFICULTY",
"SELECT DATA"
],
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
"then_video": "S00A",
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis.",
"skippable": true,
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
"after_video": {
"goto": "title",
"kind": "authored",
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
}
},
"ptbtn02": {
"label": "LOAD GAME",

View File

@@ -90,6 +90,10 @@ git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconc
| P3 — the plate's PULSE | **does the plate's focus record loop, and with what period?** | Q2 | ❔ **open, and the port's earlier reading of it was wrong.** The port had looked for the pulse in `ptbtn00`'s own group; `5b0a6e6` identifies it as the plate's **focus record** `ptbtn00f` — a glow ramping alpha `0x00``0x50` and back, t=6…105. Measured on the running game at 2.12 / 2.19 / 2.34 / 2.31 s, mean **2.24 s**. 🟡 **The port has not taken it.** Looping that record needs a period, and its group is 105 timed units plus the **authored** 24-unit exit ramp = 129 units = 2.15 s — composing an authored constant with a loop assumption to land on a measured number is tuning, not measuring. Separately: the port draws no focus record on `press_start` at all, because the screen has no `buttons` and nothing is focused, so *whether the game always draws it* is its own question. |
| ~~P5 focus ring — implementation~~ | ~~the ring's spin period~~ | Q1 | ✅ **implemented 2026-08-29.** `ScreenView.spin_period_units` drives it: one turn per the element's own declared `t`, looping, from the screen clock. The period comes off the **disc**; what the RE agent supplied is that the turn repeats rather than stopping. Verified on the port's own render — the ring is bit-identical one period apart across the whole frame, differs by 3.6/255 inside its box at quarter-period steps, and conserves box luminance to **0.027 %** over eight phases, which is the same observable the RE agent used to separate rotation from a pulse. 🟡 **Direction is not measured** — the port turns 0°→+360°, which is the sign the disc declares, but the RE agent's angle estimators failed their controls and no signed angle was ever taken. 🟡 **Phase across a focus change is not measured** either: the port drives the ring off the screen clock, so it does not reset when focus moves. Settled by two frames straddling a focus change. |
| P7 / naming — the four unnamed builds | **which locale and variant is each of `GP_TITLE` entries 0, 1, 12, 15?** | — | 🟢 **found by the port, not blocking, and handed over.** All four are **loading screens**: every element in all four is named `pgloading_*` (`pgloading_processing.png`, `pgloading_circle1`, `pgloading_delta`, `pgloading_ring`), and `LOADING` is one of the three screen names the Decoder read out of `sub_821C6458`. They export today as `build_00`, `build_01`, `build_12`, `build_15`. Two variants: 0/1 carry 7 elements, 12/15 carry 10 (adding `pgloading_eff00`, `pgloading_loop5`, `pgloading_baseeff`). The archive's own pairing — adjacent for 2/3, `+3` for 4…9 and 10/13, 11/14 — suggests 0 is the twin of 1 and 12 the twin of 15, but **which member is which locale is an inference and the port has not named them on it**. Naming is cheap for the Decoder and a guess for the port. |
| P7 — what fills the 4.5 s before `S00A` | **is the LOADING screen what appears between the save slot and the new-game movie?** | Q4 + Q9 | ❔ **not observed, and the port has not assumed it.** Q9 measures `S00A.wmv` starting ~4.5 s after Ⓐ on the save slot. The run that would have shown what is on screen for those 4.5 s hit the documented `sub_823070B0` cache crash after `SELECT DATA`. `GP_TITLE` carries a loading screen (row above) and 4.5 s is about the right shape for one, and that is **exactly why it is written here and not in `flow.json`**. Settled by one run that reaches the movie without crashing. |
| P3 — a second `rest.t` casualty | **the loading screen's fade quad rests OPAQUE BLACK** | — | 🔴 **noted, not fixed.** `pgloading_eff00.prm` on entries 12/15 is a 1280×720 black quad whose group is `0xff000000` at t=38, `0xff000000` at t=48, then `0x00000000` on the untimed final — black, held, *then* clear. Its `rest.t` is **38**, where it is fully opaque. So a port that draws this screen at its `rest` draws **a black rectangle over the whole loading screen**. This is the same `settle_time()`/`rest.t` problem as the row below, in a form where it hides the entire screen rather than dimming it — and it will bite whoever first draws a loading screen. |
## Answered since this file was last written — no longer blocking
Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s

View File

@@ -1924,3 +1924,174 @@ printed, and the only symptom was a frame slightly darker than the previous
run's. A gate artifact that silently drifts is the failure mode this project
keeps meeting — and it was caught only because there was a previous capture to
compare against.
## P7 — the new-game intro, 2026-08-29
`S00A.wmv` has been in `export/video/` since P4 (MISSION §6 put both movies in
scope at once). What P7 needed was for something to *play* it and for the run to
end somewhere defined.
### The port skips two measured screens, and says so on screen
The real chain is **`NEW GAME``DIFFICULTY``SELECT DATA` → Ⓐ on a save slot
→ ~4.5 s → `S00A`** (HANDOFF Q4 measured the screens, Q9 decoded the movie and
then measured its onset off the running game at 0.961.000 with a strictly
monotone playhead over 25 consecutive 0.5 s samples).
`DIFFICULTY` and `SELECT DATA` are measured destinations that are **not
`GP_TITLE` builds**, so no screen file exists to go to. The port therefore jumps
from `NEW GAME` to the one thing in that chain it has.
That is a gap, not a sequence, and the whole design here is about not letting it
read as one:
* `MenuFlow.accept` returns a **new kind**, `video`, rather than folding this
into `blocked`. The caller has to announce the skip, and a distinct kind is
what forces it to.
* The runtime prints it every time:
`(NEW GAME) -> the real chain is DIFFICULTY -> SELECT DATA, then the movie.
Neither screen is in this export.`
* `authored/flow.json` carries `skipped_chain` as **data**, so the names of what
is missing live beside the decision rather than inside a GDScript string.
A port that quietly jumped from `NEW GAME` to the intro would be showing a
sequence the game does not have, with nothing on screen saying so. That is the
exact failure this project keeps meeting from the other direction.
### What happens after the movie is authored, and had to be
The game goes into **mission 1**. Gameplay is out of scope (PORT-MISSION §7), so
"returns to a defined state" is a decision, and P7's gate says as much. The port
returns to the **title**: the boot's own end state, so a run that finishes the
intro lands somewhere a player can start again from. Nothing measured says the
game does this, and `after_video.kind` is `"authored"`.
### The 4.5 s gap is left empty on purpose
Q9 measures the movie starting ~4.5 s after Ⓐ on the save slot. What is on screen
for those 4.5 s was never observed — the run that would have shown it hit the
documented `sub_823070B0` cache crash after `SELECT DATA`.
`GP_TITLE` **does** carry a loading screen (below), and 4.5 s is about the right
shape for one. That is precisely why it is in `BLOCKED.md` and not in
`flow.json`: a plausible filler that nobody watched is the kind of thing that is
indistinguishable from a measurement a month later.
### A script timeout that would have failed every movie
`--script`'s per-step timeout is 20 s, to stop an unattended run waiting forever
on a screen that never settles. `S00A` is **93.9 s**, so the first scripted
new-game run would have been killed at step 1 and reported as "never settled".
Raising the constant would have been wrong in the other direction: a movie stuck
at frame 0 would then hang the job, and a job that waits is worse than a job that
fails, because it does not look like a failure.
So the test is **liveness, not duration**: while `get_stream_position()`
advances, the deadline moves with it; a stalled movie still trips the same 20 s.
### Found while looking: `GP_TITLE`'s four unnamed builds are LOADING screens
`build_00`, `build_01`, `build_12`, `build_15` have never had names. Every
element in all four is `pgloading_*``pgloading_processing.png`,
`pgloading_circle1`, `pgloading_delta`, `pgloading_ring` — and `LOADING` is one
of the three screen names the RE agent read out of the title part's state
function.
Two variants: 0/1 carry 7 elements, 12/15 carry 10.
**They are not renamed here.** The archive's own pairing (adjacent for 2/3, `+3`
for 4…9 and for 10/13, 11/14) suggests 0 is 1's twin and 12 is 15's, but which
member of each pair is which **locale** is an inference, and a name is exactly
the kind of thing that stops being questioned once written. Handed to the RE
agent, who can answer it from a capture in one look. `BLOCKED.md` has the row.
⚠️ **And one of them is a second casualty of the `rest.t` problem.**
`pgloading_eff00.prm` on entries 12/15 is a full-screen black quad whose group
runs `0xff000000` at t=38 → `0xff000000` at t=48 → `0x00000000` untimed: black,
held, *then* clear. Its `rest.t` is **38**, where it is fully opaque. A port that
draws that screen at its declared rest draws **a black rectangle over the entire
loading screen**. The title's case only dimmed a frame; this one hides
everything. Filed with the `settle_time()` row it belongs to.
### Refutation — attempted on the fade-quad census; it survives, with a caveat
**The claim** (HANDOFF, on transitions): *"in `GP_TITLE` exactly the six screen
builds carry it while the six overlays do not"*, where "it" is the full-screen
black `.prm` quad *whose keyframe group is the transition*.
**The test**, over the whole export: count builds carrying a full-screen
primitive with black in its keyframes.
```
16 builds exported; 12 carry one.
Of the 12 `is_build` bundles (excluding the 4 authored splashes): 8 carry, 4 do not.
carry: title, title_jp, main_menu, main_menu_jp, extras, extras_jp,
build_12, build_15
do not: press_start, press_start_jp, build_00, build_01
```
8 and 4, not 6 and 6. But the two extras are `build_12` / `build_15`, and their
quad is a **different shape**:
| | transition quad (`pteff00.prm`, title) | loading quad (`pgloading_eff00.prm`) |
|---|---|---|
| | `0xff000000` t=16 | `0xff000000` t=38 |
| | `0x00000000` t=261 | `0xff000000` t=48 |
| | `0xff000000` untimed | `0x00000000` untimed |
| shape | black → clear → **black** | black → held → **clear** |
The transition quad returns to black on exit; the loading quad does not. Read
strictly — the quad *whose group is the transition* — the claim holds.
**Verdict: survives.** The refinement is worth recording anyway, because the
naive test over-counts by two and somebody will run the naive test. There are
**two kinds** of full-screen black `.prm` in `GP_TITLE`, and only one of them is
a transition.
## P7 gate
```
godot --path port -- --menu --script=accept --audio=…/p7.wav
```
```
menu on main_menu, focus ptbtn01
script[1] accept
(NEW GAME) -> the real chain is DIFFICULTY -> SELECT DATA, then the movie.
Neither screen is in this export.
-> video S00A at 1.18 s (/work/export/video/S00A.ogv)
video ended at 94.93 s
-> title (authored: authored)
menu on title, focus (none -- this screen has no focusable item)
script complete after 99.28 s on title
recorded 98.453 s of Master bus (driver Dummy)
```
The movie ran **93.75 s** against a declared 93.9 s, the run ended on the title,
and the Master bus recorded 98.453 s: `pcm_s16le`, 44.1 kHz stereo, RMS
**22.2 dBFS**.
**What this does not show, stated because it would be easy to imply otherwise:**
the recording contains the menu bed *and* the movie together, and this run did
**not** separate them. So it establishes that the engine reached an output for
98 s of a run whose middle 94 s was a movie — not that `S00A`'s own audio track
is in the mix. Separating them wants the P6 null-control method (a paired run
that differs only in the movie), and that is not done here.
🔴 **Peak 0.0 dBFS again.** The same runtime-headroom problem P6 filed: per-file
levels are the disc's and are fine, the Master bus has no headroom, and the port
has set no bus balance because nothing measures one.
### One more file read while it was being written
`ls` reported the recording as **3 702 828 B**; `ffprobe` on the finished file
reports **17 367 084 B / 98.452608 s** — a factor of 4.7. `ffprobe` is right and
the `ls` caught it mid-flight.
`AUDIO-VERIFICATION.md` opens by naming this failure and the port has had the
temp-name-then-rename discipline since P6, which is what makes it worth writing
down rather than shrugging off: **the discipline protects a reader who opens the
path, and it does not protect a reader who stats it at the wrong moment.** Size
on disk is not a measurement of a file somebody else is still writing. Ask the
decoder, not the directory entry.

View File

@@ -369,9 +369,27 @@ func _video_finished() -> void:
print(" video ended at %.2f s" % _elapsed)
_player.queue_free()
_player = null
# A movie the MENU started (P7) returns to an authored screen; a movie the
# BOOT started advances the sequence. Two different owners, and conflating
# them walked the boot sequencer off the end of its own array.
if not _video_then.is_empty():
var after := _video_then
_video_then = {}
var goto := String(after.get("goto", ""))
print(" -> %s (authored: %s)" % [goto, String(after.get("kind", "authored"))])
_menu_activate({"kind": "enter", "goto": goto, "label": "after the movie"})
# `_menu_activate` only arms the transition; the screen it is leaving has
# already gone, so arrive immediately rather than fading out a movie.
if _pending != null:
_menu_arrive()
return
_advance()
## Where a menu-started movie goes when it ends. Empty for a boot-started one.
var _video_then: Dictionary = {}
func _unhandled_input(event: InputEvent) -> void:
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
# at 57 s against a 193 s baseline.
@@ -433,6 +451,17 @@ func _menu_activate(action: Dictionary, cue: String = "") -> void:
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
_pending = action
view.holding = false
"video":
# P7. Announce the gap before opening it. `skipped` names the
# MEASURED screens this export does not carry, and printing them is
# not a nicety: the port is about to show a sequence the game does
# not have, and the only thing that keeps that honest is saying so.
var skipped: Array = action.get("skipped", [])
if not skipped.is_empty():
print(" (%s) -> the real chain is %s, then the movie. Neither screen is in this export."
% [action.get("label", ""), " -> ".join(PackedStringArray(skipped))])
_video_then = action.get("after", {})
_play_video(String(action["video"]), bool(action.get("skippable", false)))
"blocked":
# A real, measured destination that is not in this export. Say which
# -- silence here would read as a dead button.
@@ -634,8 +663,21 @@ func _press(action: String) -> void:
## reached its own hold. Shooting before that would photograph a fade.
func _script_settled(what: String) -> bool:
var deadline := _elapsed + SCRIPT_STEP_TIMEOUT
var playhead := -1.0
while _pending != null or _player != null or not view.holding \
or view.time_units < view.settle_time():
# A movie is not a screen that failed to settle: `S00A` runs 93.9 s and
# would trip a 20 s timeout every time (P7). But "wait as long as it
# takes" would turn a movie stuck at frame 0 into a job that hangs
# forever, which is the worse failure -- it does not fail, it waits.
#
# So the test is LIVENESS, not duration: while the playhead advances the
# deadline moves with it, and a stalled movie still trips the same 20 s.
if _player != null:
var now := _player.get_stream_position()
if now > playhead:
playhead = now
deadline = _elapsed + SCRIPT_STEP_TIMEOUT
if _elapsed > deadline:
# Stop the run. Carrying on would write a whole filmstrip of the
# screen that got stuck and call it a walk through the menus.

View File

@@ -110,6 +110,10 @@ func move(step: int, buttons: Array) -> bool:
## {"kind": "blocked", "label": …, "why": …} -- a real destination
## that is not in this
## export
## {"kind": "video", "video": …, "skipped": […], -- the destination is
## "after": {…}} absent but its chain
## ends in a movie we
## DO have (P7)
## {"kind": "none"} -- nothing bound
##
## `blocked` is not an error and is not an unknown. Those five destinations were
@@ -127,6 +131,24 @@ func accept(buttons: Array) -> Dictionary:
return NONE
var label := String(button.get("label", focus()))
if button.get("goto", null) == null:
# A destination this export does not carry, but whose CHAIN ends in
# something it does: `NEW GAME` opens `DIFFICULTY`, then `SELECT DATA`,
# and only then the new-game movie. The port has the movie and neither
# screen (P7).
#
# This is returned as its own kind rather than folded into `blocked`,
# because the caller has to announce the skip. A port that quietly
# jumped from `NEW GAME` to the intro would be showing a sequence the
# game does not have, and nothing on screen would say so.
if button.get("then_video", null) != null:
return {
"kind": "video",
"label": label,
"video": String(button["then_video"]),
"skipped": button.get("skipped_chain", []),
"skippable": bool(button.get("skippable", false)),
"after": button.get("after_video", {}),
}
return {"kind": "blocked", "label": label, "why": String(button.get("blocked", ""))}
return {"kind": "enter", "goto": String(button["goto"]), "label": label}