diff --git a/authored/flow.json b/authored/flow.json index 752ddfa8..3c514962 100644 --- a/authored/flow.json +++ b/authored/flow.json @@ -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", diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index b309c6c1..46bb6b5f 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -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 diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 30958881..20d08874 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -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.96–1.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. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 978b1600..324da239 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -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. diff --git a/port/scripts/menu_flow.gd b/port/scripts/menu_flow.gd index 87168c53..f27828b3 100644 --- a/port/scripts/menu_flow.gd +++ b/port/scripts/menu_flow.gd @@ -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}