From eef45ecfd60d29c56e38fe821fc4bc5eedda73c9 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 11:27:05 +0000 Subject: [PATCH] port: P5 -- the menus navigate, and the focus ring is drawn wrong on purpose P5's gate is "a human clicks through it". The artifact is a scripted walk that proves the wiring rather than the intent -- up (wraps 01->05), five down, (A) into EXTRAS, down, (B) back, landing on the main menu with focus RESTORED to EXTRAS, ten PNGs one per settled step: xvfb-run -a godot --path port -- --menu \ --script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5 --script posts InputEventAction through Input.parse_input_event so the presses arrive at _unhandled_input exactly as a d-pad's would. Calling MenuFlow directly would have been shorter and would have proved nothing: the wiring between a press and the cursor is the part most likely to be broken, and a direct call is exactly the part that skips it. Derived vs authored, which P5 is the easiest place to blur: * DERIVED -- the ORDER of the items, from each screen file's `buttons`, which the exporter already fills from button-role elements sorted by resting Y. * AUTHORED -- destinations, initial focus, what (B) does, and left/right being a no-op. All measured off the running game (HANDOFF Q4/Q5) or chosen, none on the disc, all in authored/flow.json with a why. Four of five main-menu destinations are `goto: null` with a `blocked` note. That is a MILESTONE BOUNDARY, not an unknown -- DIFFICULTY, the save list, the lesson list and OPTIONS were all measured and live in archives this export does not carry. `blocked` and `none` are kept apart so nobody later "discovers" the gap. --headless CANNOT DRAW, and the port hung instead of saying so. Measured, not assumed: under --headless Godot's dummy renderer never emits RenderingServer.frame_post_draw, so every capture path awaited it forever -- --capture since P1, --film since P3, --shots as of now. With stdout block- buffered the observable behaviour was SILENCE, FOREVER, which in a loop reads as a job still working. Isolated by `--quit` (prints, exits 0) vs `--capture` (zero bytes, killed at 40 s). Now those three flags refuse at STARTUP naming the xvfb-run line that works, and --script no longer waits for a frame it is not going to photograph -- so headless walks the menus in 4.5 s as a cheap regression check needing no X server. REFUTATION ATTEMPT, against the Decoder's 7eeae30 point 2 ("the oracle confirms the game renders the ring's rotation"). Aimed there because PROTOCOL says to aim at a claim the port is about to build on that rests on an estimator whose own control the Decoder reported as +/-19.8 deg. IT SURVIVES, more strongly than claimed. Both captures draw the SAME sprite (ptbtneff01) 240 px apart, so "is it drawn rotated" becomes "are these two crops one image at a different angle" -- no crop offset needed and no reference to our own renderer. 360-bin angular luminance profile over the annulus, circularly cross-correlated. Two controls first: known rotations 0/30/90/150/210/270/330 recovered with 0 deg error, and a ring-free patch of the same capture peaks at 0.369, so the estimator does not manufacture matches. Then: A vs B 134 deg (corr 0.968), sprite vs A 76 deg, sprite vs B 210 deg -- and 210-76 = 134, which nothing in the method forced. So 0 deg is NOT A POSE THE GAME SHOWS, and screen_view.gd draws the ring at 0 deg. That is now stated in the code as known-wrong rather than suspected. The port did NOT start spinning it: the period has two unknowns and both are the Decoder's -- the second keyframe is untimed, and "groups hold" predicts a stop at 360 = 0 which contradicts both captures. Two frames of one focused button a known time apart settle it. Filed in BLOCKED.md and asked over the channel. BLOCKED.md's staleness check was half a check. It tested whether that page is stale relative to HANDOFF; it cannot see the other direction, and the other direction is what happened -- 7eeae30 lands 27 minutes AFTER HANDOFF was last written and answers a question HANDOFF still lists as open. Added the missing half: `git log --oneline 9ca1eb5..HEAD -- docs/re/`. Also recorded, since the two were nearly confused: the ring's annulus centroid lands within ~0.4 px of its design position under a ZERO crop offset, which corroborates ORACLE-CAPTURES' "1279x675, top-left aligned" on a feature nobody chose for the purpose. The earlier "text bands at design y + 23" is an offset WITHIN the button sprite, not a crop offset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey --- authored/flow.json | 125 ++++++++++++++- docs/port/BLOCKED.md | 24 ++- docs/port/DECISIONS.md | 210 ++++++++++++++++++++++++ port/scripts/boot.gd | 290 ++++++++++++++++++++++++++++++++-- port/scripts/menu_flow.gd | 165 +++++++++++++++++++ port/scripts/menu_flow.gd.uid | 1 + port/scripts/screen_view.gd | 21 ++- 7 files changed, 808 insertions(+), 28 deletions(-) create mode 100644 port/scripts/menu_flow.gd create mode 100644 port/scripts/menu_flow.gd.uid diff --git a/authored/flow.json b/authored/flow.json index 5025fa5d..feedd4ee 100644 --- a/authored/flow.json +++ b/authored/flow.json @@ -43,13 +43,126 @@ "When a capture times the real boot, the extra hold per screen goes here." ] }, + "navigation": { + "_": [ + "MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.", + "It lives here rather than in GDScript so that a reader can see it is a", + "measurement and delete it the day a field on the disc states it." + ], + "wrap": true, + "wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).", + "left_right": "nothing", + "left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.", + "input_during_transition": "ignored", + "input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it." + }, "screens": { "_": [ - "What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", - "destination screens and the RE agent later decoded that a transition is a", - "lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,", - "LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`", - "targets when this is written, marked as the name match they are." - ] + "What each button does. The NAVIGATION ORDER is not here -- it is derived,", + "in each screen file's `buttons` (button-role elements sorted by resting Y).", + "Only the destinations, the initial focus and the cancel target are", + "authored, because only those are measurements or decisions.", + "", + "`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own", + "screen vocabulary from the decoded transition lookup -- carried so the", + "binding is not lost, and marked below as the NAME MATCH it is, never as a", + "measurement (HANDOFF: the strings are what the call sites reference, not", + "proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).", + "", + "`goto: null` with a `blocked` note means the destination screen is real and", + "measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a", + "milestone boundary, not an unknown." + ], + "title": { + "on_accept": { + "goto": "main_menu", + "goto_name": "TITLE_MENU", + "goto_name_kind": "name match, not measured", + "why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run." + }, + "on_cancel": null, + "on_cancel_why": "MEASURED, HANDOFF Q5: (B) on the title does nothing." + }, + "main_menu": { + "initial_focus": "ptbtn01", + "initial_focus_kind": "authored", + "initial_focus_why": "AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it.", + "on_cancel": { + "goto": "title", + "goto_name": "TITLE_SCREEN", + "goto_name_kind": "name match, not measured", + "kind": "authored -- likely but UNPROVEN", + "why": "HANDOFF Q5 states (B) on the main menu returns to the title. That is the ONE navigation rule on this screen with no capture behind it: it was seen once, without a capture, and the title also returns on its own after ~8-10 s idle, so an observer could not have told the two apart (docs/re/menu-navigation-semantics.md). Independently, the main menu is the ONLY screen whose footer does not advertise (B) -- confirmed here from the disc art, see docs/port/DECISIONS.md. The port implements it because a menu with no way out is worse than a menu with a plausible one, and records that it is authored." + }, + "buttons": { + "ptbtn01": { + "label": "NEW GAME", + "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." + }, + "ptbtn02": { + "label": "LOAD GAME", + "goto": null, + "goto_name": null, + "blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED." + }, + "ptbtn03": { + "label": "TUTORIAL", + "goto": null, + "goto_name": "TUTORIAL_MENU", + "goto_name_kind": "name match, not measured", + "blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED." + }, + "ptbtn04": { + "label": "OPTIONS", + "goto": null, + "goto_name": null, + "blocked": "The settings menu is GP_OPTIONS, not in this export. Destination MEASURED." + }, + "ptbtn05": { + "label": "EXTRAS", + "goto": "extras", + "goto_name": "EXTRA_MENU", + "goto_name_kind": "name match, not measured", + "why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk." + } + }, + "labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text." + }, + "extras": { + "initial_focus": "ptbtn11", + "initial_focus_kind": "measured", + "initial_focus_why": "MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.", + "on_cancel": { + "goto": "main_menu", + "goto_name": "TITLE_MENU", + "goto_name_kind": "name match, not measured", + "why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png." + }, + "buttons": { + "ptbtn11": { + "label": "MISSION SELECT", + "goto": null, + "goto_name": null, + "blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED." + }, + "ptbtn12": { + "label": "MOVIE THEATER", + "goto": null, + "goto_name": null, + "blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination." + }, + "ptbtn13": { + "label": "BACK", + "goto": "main_menu", + "goto_name": "TITLE_MENU", + "goto_name_kind": "name match, not measured", + "same_as_cancel": true, + "why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference." + } + } + } } } diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 6ee90dd7..1476ab74 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -13,6 +13,26 @@ for, so it is deleted rather than forgotten when the answer arrives. Reconciled **2026-08-29** against [`docs/port/HANDOFF.md`](HANDOFF.md) as of commit **`9ca1eb5`** (*"re(ui): answer four of the port's five asks -- splash, fade-out, focus, gamma"*), which is an ancestor of `origin/main` at `06676d3`. +Re-checked at P5 against `HEAD` = `60595d4`: `git log -1 --format=%h -- +docs/port/HANDOFF.md` still answers `9ca1eb5`, so HANDOFF itself has not moved. + +🔴 **HANDOFF has not moved, and that is now the problem.** The check above tests +whether *this page* is stale relative to HANDOFF. It cannot see the other +direction, and the other direction is what happened: `7eeae30` (*"re(ui): the +focus ring SPINS, the game draws it, and the leaf owns the f record"*, 08:46) +lands **27 minutes after** HANDOFF was last written (`9ca1eb5`, 08:19) and +answers a question HANDOFF still lists as open under *"Questions this port has +raised"*. Both are ancestors of `HEAD`. + +So the staleness check needs a second half, and this is it: + +```sh +git log --oneline 9ca1eb5..HEAD -- docs/re/ # RE landed since HANDOFF was written? +``` + +Anything it lists may already answer a row below. The Decoder has been told over +the message channel that HANDOFF needs `7eeae30` folded in; **rewriting HANDOFF +is not the port's to do.** ⚠️ **The address of HANDOFF.md changed and this page did not notice.** The previous line here cited `/reborn` HEAD `9a0ca0d`. Two things have since made @@ -45,6 +65,8 @@ git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconc | ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. | | P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. | | P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. | +| P5 focus marker | **the focus ring's spin PERIOD, and whether it loops** | Q1 + the 2026-08-28 *"groups hold"* answer | ❔ open, and the port is drawing a pose it knows is wrong. Derived at HANDOFF `9ca1eb5` **plus** `7eeae30`, which HANDOFF does not yet carry. `ptbtneff01` declares `t=120, rot 0` then an **untimed** `rot 360`. The port measured the two oracle captures at **~76°** and **~210°** — 134° apart, peak corr 0.968, null control 0.369 (`DECISIONS.md`) — so **0° is not a pose the game shows**, and the port draws 0° because a spin rate would be invented. Two unknowns, both the Decoder's: (a) under Q1's *replicated* reading `t=120` is when the **next** pose is reached, giving one revolution in 2.0 s, but this port's `pose_at` implements the other reading and switching it changes every screen's animation timing; (b) *"groups hold"* predicts a stop at 360 = 0, which contradicts both captures. **What settles it: two frames of one focused button a known time apart.** | +| P5 — Ⓑ on the main menu | **is Ⓑ what returns to the title, or the idle timer?** | Q5 | 🟡 stated in HANDOFF, no capture behind it. The title self-returns after ~8–10 s idle, so one unrecorded observation cannot separate them. `authored/flow.json` implements it and marks it *authored — likely but UNPROVEN*. **Not blocking** — P5 shipped with it — but it is the only navigation rule on that screen with nothing under it. Settled by one run that presses Ⓑ well inside the idle window, timestamped. | ## Answered since this file was last written — no longer blocking @@ -82,7 +104,7 @@ than exported: | Authored because it is not on the disc | HANDOFF | Where it lives | |---|---|---| | `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 | -| initial menu focus (not stable across boots; pick one and say so) | Q5 | `authored/menu.json` — P5, this iteration | +| initial menu focus (not stable across boots; pick one and say so) | Q5 | `authored/flow.json`, `screens.main_menu.initial_focus` — landed at P5 | | the ~0.4 s fade-out and the 0.17–0.23 s black hold | Q7 | not yet written — P3 | ## The five asks — four answered at `9ca1eb5` diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 5239929c..14afa2da 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -928,3 +928,213 @@ next iteration does not rebuild the same broken tool. the group-loop question again — answered "groups hold" for build-in animations, but a 360° hold and a 0° hold are the same pose, so *this* group cannot be told apart by its rest pose. Not guessed; raised below. + +--- + +## P5 — navigation, 2026-08-29 + +The gate is *"a human clicks through it"*, and the artifact is a walk that +proves the wiring rather than the intent: `up` (which wraps 01→05), five `down`, +Ⓐ into `EXTRAS`, `down`, Ⓑ back — landing on the main menu with focus restored +to `EXTRAS`. + +```sh +xvfb-run -a godot --path port -- --menu \ + --script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5 +``` + +Ten PNGs, one per step, each taken after the screen it produced had settled. +Contact sheet handed over as `share` id `1788002507-ef4468a0a33a`. + +### The scripted walk goes through the input system, not around it + +`--script` posts `InputEventAction` through `Input.parse_input_event` and lets +it arrive at `_unhandled_input` exactly as a d-pad's press would. Calling +`MenuFlow.move()`/`accept()`/`cancel()` directly would have been shorter and +would have proved nothing: the thing most likely to be broken is the wiring +between a press and the cursor, and a direct call is precisely the part that +skips it. The same reasoning says the settle wait must be real — a shot taken +before the screen stops moving photographs a fade and calls it a menu. + +### What is authored here, and what is derived + +Split deliberately, because P5 is where the two are easiest to blur: + +| | where | why | +|---|---|---| +| the ORDER of the items | **derived** — each screen file's `buttons`, filled by the exporter from the button-role elements sorted by resting Y | it is on the disc | +| where an item goes | **authored** — `authored/flow.json` | HANDOFF Q4 *measured* the destinations; they are not in the file | +| which item opens focused | **authored** | Q5 measured that it is **not stable boot to boot** | +| what Ⓑ does | **authored** | Q5, measured — except on the main menu, see below | +| ⬅➡ do nothing | **authored**, written as an explicit no-op | so that *"the game ignores it"* and *"we never wired it"* are different lines of code | + +Four of the five main-menu destinations are `goto: null` with a `blocked` note. +That is **not** an unknown: `DIFFICULTY`, the save-slot list, the lesson list and +the settings menu were all measured, and they live in archives this export does +not carry. `blocked` and `none` are kept apart so a later reader does not +"discover" a gap that was a milestone boundary. + +`EXTRAS` is the only main-menu destination inside `GP_TITLE`, and therefore the +only Ⓐ-into-a-submenu this gate can actually walk. + +### The one navigation rule with nothing behind it + +Ⓑ on the **main menu** → title. HANDOFF Q5 states it, and `flow.json` marks it +*authored — likely but UNPROVEN*, because the title also self-returns after +~8–10 s idle and a single unrecorded observation cannot separate the two. The +port implements it anyway — a menu with no way out is worse than a menu with a +plausible one — and says in the file that it did. Asked of the Decoder this +iteration; see `BLOCKED.md`. + +Independent corroboration that the main menu is different from its submenu: +the main menu's footer advertises only `Ⓐ : OK`, while `EXTRAS`' footer +advertises `Ⓑ : Back`. That is on the disc, in `ptmsg.png` vs `ptmsg2.png`, and +it is visible in both the port's render and the captures. + +### A press during a fade is dropped + +**Authored, and not measured.** Nobody has watched what the game does with a +button pressed mid-transition. Dropping invents less than queueing does: it +cannot manufacture a press the game might have discarded. `flow.json` says so +under `navigation.input_during_transition`. + +--- + +## `--headless` cannot draw, and the port hung instead of saying so, 2026-08-29 + +`docs/port/PORT-MISSION.md` and the loop prompt both name `godot-headless` as +how this project runs unattended. It does not work, and the way it failed was +the worst available shape. + +**Measured, not assumed.** Under `--headless` Godot's dummy renderer never emits +`RenderingServer.frame_post_draw`. Every capture path in `boot.gd` awaits it — +`--capture` since P1, `--film` since P3, `--shots` as of this milestone — so all +three blocked forever. Isolated by the difference between two runs: + +``` +godot --headless --path port --quit # prints, exits 0 +godot --headless --path port -- --screen=… --capture=… # no output at all, killed at 40 s +``` + +The second produces **zero bytes of output** before it is killed, because +Godot's stdout is block-buffered and never flushes. So the observable behaviour +of an unattended headless capture was: silence, forever. In a loop, a job that +waits reads as a job still working — this is the failure mode that costs a whole +iteration and leaves nothing behind to say what happened. + +Two changes, and deliberately not one: + +* `--capture`, `--film` and `--shots` **refuse at startup** under `--headless`, + naming the flag and printing the `xvfb-run` line that does work. Refusing + early rather than at the first frame means the run does not die halfway + through a filmstrip with some frames written. +* `--script` **no longer waits for a drawn frame when it is not going to + photograph one.** Navigation is checkable where nothing draws, and that is + worth keeping: `godot --headless --path port -- --menu --script=…` now walks + the menus and exits 0 in about four seconds, which is a cheap regression check + that needs no X server at all. + +The Xvfb path is unchanged and is what produced the P5 artifact. + +--- + +## Refutation — the focus ring IS drawn rotated, and it is not at 0° in either capture + +Attempted against the Decoder's `7eeae30` (*"re(ui): the focus ring SPINS, the +game draws it, and the leaf owns the f record"*), point 2: that in the +OPTIONS-focused capture the ring's bright head sits in a different angular +position from the sprite's own, caught mid-spin. **It survives**, and the +evidence is stronger than what was claimed. + +Chosen for refutation because it is exactly what PROTOCOL says to aim at: a +claim the port is about to build on, resting on an estimator (a brightest-region +centroid) whose own control the Decoder reported as ±19.8°. + +### The test, and why it needs no absolute registration + +`live-main-menu.png` has `ptbtn01` focused; `live-main-menu-options-focused.png` +has `ptbtn04` focused. Both draw **the same sprite**, `ptbtneff01.png` — the +export confirms the two focus records name the same file. So the two captures +contain two instances of one 42×46 image, 240 px apart in design space, and the +question *"is it drawn rotated"* becomes *"are these two crops the same image +at a different angle"* — which needs no crop offset and no reference to our own +renderer. + +Method: sample each ring into a 360-bin **angular luminance profile** over the +annulus band (r = 9…15 px, bilinear, 0.5 px radial step) and circularly +cross-correlate. A rotation about the centre shifts that vector and changes +nothing else. + +### The instrument was run through two controls before it was believed + +| control | result | +|---|---| +| rotate a capture's own ring by a known 0/30/90/150/210/270/330° and recover it | **0° error on all seven**, peak corr 1.000 | +| the same estimator on a ring-free 60×64 patch of the *same* capture | peak corr **0.369** — it does not manufacture a match | + +### The measurement + +On one shared centre for all three images, so a centroid difference cannot +masquerade as a rotation: + +| pair | best shift | peak corr | corr at 0° | +|---|---|---|---| +| capture A vs capture B | **134°** | 0.968 | −0.064 | +| sprite (unrotated) vs capture A | **76°** | 0.969 | −0.295 | +| sprite (unrotated) vs capture B | **210°** | 0.948 | −0.181 | + +210 − 76 = 134: the three measurements are internally consistent, which nothing +in the method forced them to be. Sweeping the centre by ±2 px moves the A-vs-B +answer over 117…161° while the peak correlation stays 0.9+ across the middle of +that range, so the **magnitude is ~134° ± ~15°** and the precision claim stops +there. + +Evidence sheet — sprite, capture A, capture B, each cropped at the declared +`42×46+500+156` / `+500+396` — handed over as `share` id +`1788002507-afe1ad843789`. The phase difference is obvious by eye; the numbers +are here so it is not only obvious by eye. + +### The two things this settles for the port + +1. **The game draws `rotation_deg` on an element the English boot path shows.** + This is a second, independent confirmation on a different screen and a + different element from the `ptloop` sweeps, and it moves HANDOFF **ask 4** + (*should the port draw rotation*) off "changes nothing at rest" — it changes + the main menu's focus marker, in every frame. + +2. **0° is not a pose the running game shows.** `screen_view.gd` currently draws + the ring at its `rest` pose, which is `rotation_deg 0`, and both captures put + it at 76° and 210°. So the port's focus marker is **known** to be wrong, not + suspected — and the comment in `screen_view.gd` now says which two numbers it + is wrong against. + +### Registration, as a by-product + +The ring's annulus centroid lands at (32.94, 36.63) and (33.30, 38.90) in +windows whose design-space prediction under a **zero crop offset** is +(33.0, 37.0). Within ~0.4 px on the better-thresholded of the two. That +corroborates `ORACLE-CAPTURES.md`'s *"1279×675, top-left aligned"* directly, on +a feature nobody chose for the purpose. + +⚠️ Do not read the earlier P5-groundwork note *"button text bands land at design +y + 23"* as a crop offset — it is an offset **within** the button sprite, and +the two were nearly confused here. + +### What the port did NOT do about it + +It did not start spinning the ring. The period is a **guess with two unknowns** +and both belong to the Decoder: + +* the keyframes are `t=120, rot 0` then an **untimed** `rot 360`. Under HANDOFF + Q1's replicated reading (*"`+36` is the time the NEXT pose is reached"*) that + is one revolution in 120 units = **2.0 s** — but this port's `pose_at` + implements the *other* reading, and switching it is a change to every screen's + animation timing, not a P5 change; +* *"groups hold"* (settled 2026-08-28) predicts the ring stops at 360° = 0°. + Both captures show it elsewhere. That is either a spin that loops, or two + captures both taken inside the first two seconds of focus. **The port cannot + tell those apart**, and a wrong answer here is a visible continuous rotation + on whichever button the player is sitting on. + +Filed in `BLOCKED.md` and asked over the message channel. What settles it is two +frames of one focused button a known time apart. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 8020b78f..c3919f7d 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -10,6 +10,16 @@ # godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png # godot --path port -- --boot # the whole boot sequence # godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s +# godot --path port -- --menu # P5: navigate the menus +# godot --path port -- --menu=extras # ...starting somewhere else +# godot --path port -- --boot --play # boot, then hand over to P5 +# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5 +# +# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back. +# `--script` drives the SAME input path with synthetic events -- it does not call +# the navigation functions directly, because then the artifact would prove +# nothing about whether a human's press arrives. `--shots` writes one PNG per +# scripted step, after the screen it produced has settled. # # `--time` is in SECONDS and freezes the timeline there; without it the screen # animates in real time from t=0. `--pose=rest` draws the export's declared @@ -32,6 +42,10 @@ var viewport: SubViewport = null func _ready() -> void: var args := _args() + for flag: String in ["capture", "film", "shots"]: + if args.has(flag) and not _has_display(flag): + get_tree().quit(4) + return var export_tree := ExportTree.locate() if export_tree.root == "": push_error(export_tree.error) @@ -39,17 +53,32 @@ func _ready() -> void: return _flow = export_tree.authored("flow.json") + if _flow == null and (args.has("boot") or args.has("menu")): + push_error(export_tree.error) + get_tree().quit(2) + return if args.has("boot"): - if _flow == null: - push_error(export_tree.error) - get_tree().quit(2) - return for step: Dictionary in _flow["boot"]: _sequence.append(step) - _film = args.get("film", "") + _film = args.get("film", "") + _shots = args.get("shots", "") + if args.has("script"): + _script = args["script"].split(",", false) + # P5. `--play` boots first and hands over on the title; `--menu` starts on a + # screen directly, which is what makes an unattended run cheap -- it does not + # sit through 137 s of intro to press a d-pad. + _play = args.has("play") or args.has("menu") + if _play: + _menu = MenuFlow.new() + if not _menu.configure(_flow): + push_error(_menu.error) + get_tree().quit(2) + return var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \ - else args.get("screen", DEFAULT_SCREEN) + else args.get("menu", args.get("screen", DEFAULT_SCREEN)) + if name == "1": + name = DEFAULT_SCREEN # bare `--menu` if name == "": name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport var screen: Dictionary = export_tree.screen(name) @@ -99,6 +128,10 @@ func _ready() -> void: get_tree().quit(2) return + # Not booting: `--menu` opens straight onto a screen, so the stack starts here. + if _menu != null and _sequence.is_empty(): + _menu_enter(name, true) + var settle := view.settle_time() print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [ name, view.screen["elements"].size(), view.screen["paint_order"].size(), @@ -120,6 +153,12 @@ func _ready() -> void: var _frozen := false var _flow: Variant = null +var _menu: MenuFlow = null +var _play := false +var _pending: Variant = null +var _script: PackedStringArray = PackedStringArray() +var _shots := "" +var _script_started := false var _sequence: Array[Dictionary] = [] var _player: VideoStreamPlayer = null var _step := 0 @@ -137,7 +176,18 @@ func _process(delta: float) -> void: _elapsed += delta view.queue_redraw() - if _sequence.is_empty() or _player != null: + if _player != null: + return + + # A menu transition. This is checked BEFORE the boot sequence and outside + # its emptiness guard: `--menu` has no sequence at all, and an earlier + # version returned here, so the screen faded out and nothing ever arrived. + if _pending != null: + if view.time_units >= view.exit_time(): + _menu_arrive() + return + + if _sequence.is_empty(): return # A screen holds at `rest` until it has arrived, then plays itself out and @@ -154,7 +204,13 @@ func _process(delta: float) -> void: elif not _boot_done: _boot_done = true print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]]) - if _film == "": + # P5 takes over here: the boot ends on the title and the title has + # somewhere to go. Without `--play` the run still stops, because a + # boot that ends by waiting for a key it will never get is worse + # than one that exits. + if _play: + _menu_enter(String(_sequence[_step].get("screen", "")), true) + elif _film == "": get_tree().quit(0) elif not view.holding and view.time_units >= view.exit_time(): _advance() @@ -221,14 +277,120 @@ func _video_finished() -> void: 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. This is the only input the port handles - # so far; menu navigation is P5. - if _player == null or not _skippable: + # at 57 s against a 193 s baseline. + if _player != null: + if _skippable and (event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel")): + print(" video skipped at %.2f s" % _elapsed) + _player.stop() + _video_finished() return - if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"): - print(" video skipped at %.2f s" % _elapsed) - _player.stop() - _video_finished() + if _menu == null or _menu.stack.is_empty(): + return + # AUTHORED, not measured: a press during a screen's fade-out is dropped. + # `authored/flow.json` says why -- nobody has watched what the game does + # here, and dropping invents less than queueing. + if _pending != null: + return + var buttons: Array = view.screen.get("buttons", []) + if event.is_action_pressed("ui_up"): + _menu_move(-1, buttons) + elif event.is_action_pressed("ui_down"): + _menu_move(1, buttons) + elif event.is_action_pressed("ui_left") or event.is_action_pressed("ui_right"): + # MEASURED, HANDOFF Q5: left/right do nothing. Written out rather than + # left unhandled so that "the game ignores it" and "we never wired it" + # are different lines of code. + pass + elif event.is_action_pressed("ui_accept"): + _menu_activate(_menu.accept(buttons)) + elif event.is_action_pressed("ui_cancel"): + _menu_activate(_menu.cancel()) + + +func _menu_move(step: int, buttons: Array) -> void: + if _menu.move(step, buttons): + view.focused_id = _menu.focus() + view.queue_redraw() + print(" focus -> %s" % view.focused_id) + + +## Act on what the flow returned. A destination starts the screen playing itself +## out; the arrival happens in `_process` when the exit ramp is done, so the +## fade is the transition HANDOFF Q7 measured and not a cut. +func _menu_activate(action: Dictionary) -> void: + match String(action.get("kind", "none")): + "enter": + print(" (%s) -> %s" % [action.get("label", ""), action["goto"]]) + _pending = action + view.holding = false + "blocked": + # A real, measured destination that is not in this export. Say which + # -- silence here would read as a dead button. + print(" (%s) opens a screen this export does not carry: %s" + % [action.get("label", ""), action.get("why", "")]) + _: + pass + + +## Enter a screen with the menu live. `fresh` seeds the stack rather than +## replacing the top, which is what a boot handover and `--menu` both want. +func _menu_enter(name: String, fresh: bool) -> void: + if name == "" or not _menu.known(name): + push_warning("flow.json describes no screen named %s -- navigation stops here" % name) + return + if fresh: + _menu.enter(name, view.screen.get("buttons", [])) + view.focused_id = _menu.focus() + view.queue_redraw() + print(" menu on %s, focus %s" % [name, view.focused_id]) + if not _script.is_empty() and not _script_started: + _script_started = true + _run_script() + + +## The moment a screen has finished fading out and the next one takes over. +func _menu_arrive() -> void: + var action: Dictionary = _pending + _pending = null + var name := String(action["goto"]) + view.holding = true + view.time_units = 0.0 + if not view.load_screen(view.tree, name): + push_error(view.tree.error) + get_tree().quit(2) + return + var buttons: Array = view.screen.get("buttons", []) + if action.get("pop", false): + # MEASURED, HANDOFF Q5: (B) restores the focus you came from. + _menu.pop() + _menu.stack[_menu.stack.size() - 1]["focus"] = String(action["restore_focus"]) + view.focused_id = _menu.focus() + view.queue_redraw() + print(" menu on %s, focus restored to %s" % [name, view.focused_id]) + else: + _menu_enter(name, true) + + +## Whether this process can produce a picture at all. +## +## MEASURED here, not assumed: under `--headless` Godot's dummy renderer never +## emits `RenderingServer.frame_post_draw`, so every `await` on it blocks +## forever. `godot-headless --path port -- --screen=main_menu --capture=…` +## therefore hung with NO OUTPUT until it was killed -- the same run with +## `--quit` prints and exits, which is how the difference was isolated. +## +## That is the worst shape a failure can take in an unattended loop: it does not +## fail, it waits, and a job that waits forever reads as a job still working. +## So the flags that need a frame refuse at STARTUP and say what to run instead, +## rather than dying somewhere in the middle of a filmstrip. +func _has_display(flag: String) -> bool: + if DisplayServer.get_name() != "headless": + return true + push_error(("--%s needs a drawn frame, and --headless never draws one: " + + "Godot's dummy renderer does not emit frame_post_draw, so this would " + + "hang rather than fail. Run it under Xvfb instead:\n" + + " xvfb-run -a godot --path port -- …--%s=…") % [flag, flag]) + return false func _capture(path: String) -> void: @@ -271,3 +433,101 @@ static func _args() -> Dictionary: elif arg.begins_with("--"): out[arg.substr(2)] = "1" return out + + +# ── The scripted walk ─────────────────────────────────────────────────────── +# +# `--script=down,down,accept,cancel` presses those buttons in order and, with +# `--shots=`, leaves one PNG per step behind. This is the P5 artifact for an +# unattended run. +# +# It sends synthetic events through `Input.parse_input_event`, so they arrive at +# `_unhandled_input` exactly as a d-pad's would. Calling the navigation +# functions directly would have been three lines shorter and would have proved +# nothing: the thing most likely to be broken is the wiring between a press and +# the cursor, and that is the part a direct call skips. + +const SCRIPT_ACTIONS := { + "up": "ui_up", "down": "ui_down", "left": "ui_left", "right": "ui_right", + "accept": "ui_accept", "a": "ui_accept", "cancel": "ui_cancel", "b": "ui_cancel", +} + +## How long a single step may take before the run is called stuck, in seconds. +## A screen that never settles would otherwise hang an unattended job forever; +## the title's own timeline is 4.5 s, so this is generous rather than tuned. +const SCRIPT_STEP_TIMEOUT := 20.0 + + +func _run_script() -> void: + if not await _script_settled("start"): + return + await _shoot("00_start") + for i in range(_script.size()): + var token := _script[i].strip_edges().to_lower() + if token == "wait": + pass + elif SCRIPT_ACTIONS.has(token): + print("script[%d] %s" % [i + 1, token]) + _press(String(SCRIPT_ACTIONS[token])) + else: + push_error("--script: no such step %s (have %s, wait)" % [token, ", ".join(SCRIPT_ACTIONS.keys())]) + get_tree().quit(2) + return + # `Input.parse_input_event` is flushed with the frame, not on the call. + # Without these two frames the settle check runs while the press has not + # been delivered yet, decides nothing is moving, and photographs the + # screen the press was about to leave. + await get_tree().process_frame + await get_tree().process_frame + if not await _script_settled(token): + return + await _shoot("%02d_%s" % [i + 1, token]) + print("script complete after %.2f s on %s, focus %s" + % [_elapsed, _menu.current(), view.focused_id]) + get_tree().quit(0) + + +func _press(action: String) -> void: + for down in [true, false]: + var e := InputEventAction.new() + e.action = action + e.pressed = down + Input.parse_input_event(e) + + +## Wait until nothing is moving: no transition pending, and the screen has +## reached its own hold. Shooting before that would photograph a fade. +func _script_settled(what: String) -> bool: + var deadline := _elapsed + SCRIPT_STEP_TIMEOUT + while _pending != null or _player != null or not view.holding \ + or view.time_units < view.settle_time(): + 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. + push_error("--script: %s never settled within %.0f s -- stopping" + % [what, SCRIPT_STEP_TIMEOUT]) + get_tree().quit(3) + return false + await get_tree().process_frame + # Only a run that is about to photograph the frame needs to wait for one to + # be drawn. `--script` on its own is a navigation check and must still work + # where nothing draws -- see `_has_display`. + if _shots != "": + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + return true + + +func _shoot(label: String) -> void: + if _shots == "": + return + var path := "%s_%s.png" % [_shots, label] + var img := viewport.get_texture().get_image() + # Write to a temp name and rename on completion: another agent probing a + # file this is still writing gets a confident wrong number. + var tmp := path + ".part" + if img.save_png(tmp) != OK: + push_error("cannot write %s" % tmp) + return + DirAccess.rename_absolute(tmp, path) + print(" shot %s (%s, focus %s)" % [path, _menu.current(), view.focused_id]) diff --git a/port/scripts/menu_flow.gd b/port/scripts/menu_flow.gd new file mode 100644 index 00000000..87168c53 --- /dev/null +++ b/port/scripts/menu_flow.gd @@ -0,0 +1,165 @@ +# Where the buttons go, and what the d-pad does. +# +# EVERYTHING IN HERE IS AUTHORED OR MEASURED -- none of it is on the disc. +# HANDOFF Q6 closed the "what drives the flow" question with a negative: the +# order is code, not data, in all four places it could have been. So this class +# reads `authored/flow.json` and holds no rule of its own. +# +# The split is deliberate and is the derived/authored contract in miniature: +# +# * the ORDER of the items is DERIVED -- each screen file's `buttons`, which +# the exporter fills from the button-role elements sorted by resting Y; +# * WHERE an item goes, WHICH item opens focused, and WHAT (B) does are +# AUTHORED, because they were measured off the running game or chosen. +# +# A rule that lived in GDScript instead would be invisible to the person whose +# job is to notice that we decided it. +class_name MenuFlow +extends RefCounted + +## The authored `screens` map: screen name -> destinations, initial focus, (B). +var screens: Dictionary = {} +## The authored `navigation` block: wrap, left/right, input during a transition. +var navigation: Dictionary = {} + +## Where we are and how we got here, oldest first. The last entry is current. +## (B) restores the focus recorded on the entry it pops back to -- HANDOFF Q5 +## measured that the game does this, so the stack carries a focus, not just a +## name. +var stack: Array[Dictionary] = [] + +var error: String = "" + +## Nothing happened. Returned rather than `null` so a caller reads one shape. +const NONE := {"kind": "none"} + + +func configure(flow: Variant) -> bool: + if typeof(flow) != TYPE_DICTIONARY: + error = "authored/flow.json did not parse to an object" + return false + if not flow.has("screens") or not flow.has("navigation"): + error = "authored/flow.json has no `screens`/`navigation` block -- this build needs both" + return false + screens = flow["screens"] + navigation = flow["navigation"] + return true + + +func known(name: String) -> bool: + return screens.has(name) and typeof(screens[name]) == TYPE_DICTIONARY + + +func current() -> String: + return String(stack[stack.size() - 1]["screen"]) if not stack.is_empty() else "" + + +func focus() -> String: + return String(stack[stack.size() - 1]["focus"]) if not stack.is_empty() else "" + + +## The item a screen opens on. +## +## Authored per screen. Where the authored value names a button this screen does +## not have -- a mistyped id, or an export whose buttons moved -- fall back to +## the first button rather than to nothing, and SAY SO: a menu that opens with +## no focus looks like a rendering bug, and this is the one place that mistake +## would hide. +func initial_focus(name: String, buttons: Array) -> String: + if buttons.is_empty(): + return "" + var want := String(screens.get(name, {}).get("initial_focus", "")) + if want != "" and buttons.has(want): + return want + if want != "": + push_warning("flow.json opens %s on %s, which is not one of its buttons %s" % [name, want, buttons]) + return String(buttons[0]) + + +func enter(name: String, buttons: Array) -> void: + stack.append({"screen": name, "focus": initial_focus(name, buttons)}) + + +## Move the cursor. Returns true when it actually moved, so a caller can fire the +## move cue only on a real move (P6) rather than on every press. +## +## MEASURED, HANDOFF Q5: up/down move one item and WRAP at both ends -- on the +## 5-item main menu and the 3-item EXTRAS both, so it is a menu rule. `wrap` is +## read from `authored/flow.json` rather than written here, because it is a +## measurement and the day it is contradicted the fix is a data edit. +func move(step: int, buttons: Array) -> bool: + if stack.is_empty() or buttons.size() < 2: + return false + var at := buttons.find(focus()) + if at < 0: + at = 0 + var to := at + step + if bool(navigation.get("wrap", true)): + to = posmod(to, buttons.size()) + else: + to = clampi(to, 0, buttons.size() - 1) + if to == at: + return false + stack[stack.size() - 1]["focus"] = String(buttons[to]) + return true + + +## (A). Returns what the authored flow says the focused item opens. +## +## {"kind": "enter", "goto": , "label": …} -- go there +## {"kind": "blocked", "label": …, "why": …} -- a real destination +## that is not in this +## export +## {"kind": "none"} -- nothing bound +## +## `blocked` is not an error and is not an unknown. Those five destinations were +## measured off the running game; they live in other archives and this milestone +## does not export them. Saying "blocked" rather than "none" keeps the two apart. +func accept(buttons: Array) -> Dictionary: + if stack.is_empty(): + return NONE + var screen: Dictionary = screens.get(current(), {}) + # A screen with no buttons -- the title -- can still take (A). + if buttons.is_empty(): + return _target(screen.get("on_accept", null), "A") + var button: Dictionary = screen.get("buttons", {}).get(focus(), {}) + if button.is_empty(): + return NONE + var label := String(button.get("label", focus())) + if button.get("goto", null) == null: + return {"kind": "blocked", "label": label, "why": String(button.get("blocked", ""))} + return {"kind": "enter", "goto": String(button["goto"]), "label": label} + + +## (B), and EXTRAS' own `BACK` item, which is treated as the same thing -- +## nothing measured distinguishes them and inventing a difference would be a +## guess with no evidence behind it. +## +## MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you +## came from. So the target comes from the authored flow, but the focus comes +## from the STACK -- and only when the stack agrees about where we are going. A +## run that started straight on a submenu has no history to restore and enters +## the parent at its authored initial focus instead. +func cancel() -> Dictionary: + if stack.is_empty(): + return NONE + var target: Variant = screens.get(current(), {}).get("on_cancel", null) + var out := _target(target, "B") + if out["kind"] != "enter": + return out + if stack.size() >= 2 and String(stack[stack.size() - 2]["screen"]) == out["goto"]: + out["restore_focus"] = String(stack[stack.size() - 2]["focus"]) + out["pop"] = true + return out + + +## Pop back to the parent, keeping the focus it was left on. +func pop() -> void: + if stack.size() >= 2: + stack.pop_back() + + +static func _target(target: Variant, label: String) -> Dictionary: + if typeof(target) != TYPE_DICTIONARY or target.get("goto", null) == null: + return NONE + return {"kind": "enter", "goto": String(target["goto"]), "label": label} diff --git a/port/scripts/menu_flow.gd.uid b/port/scripts/menu_flow.gd.uid new file mode 100644 index 00000000..415ed607 --- /dev/null +++ b/port/scripts/menu_flow.gd.uid @@ -0,0 +1 @@ +uid://dyqb3b450d21x diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 5b111f9a..36898d07 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -326,12 +326,21 @@ func _draw_focus(element: Dictionary) -> void: if tex == null: skipped.append("%s (focus sprite failed to load)" % fe.get("id", "")) continue - # The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360 - # with position, scale and alpha all constant -- but the PERIOD is not - # established: the ramp's second keyframe is untimed, and what an untimed - # keyframe means inside a leaf (rather than at screen level, where it is - # the exit) is untested. So this holds the resting angle and does not - # invent a spin rate. + # The ring's rest pose, which is rotation_deg 0. + # + # ⚠️ THIS IS KNOWN TO BE WRONG, and is drawn anyway because the right + # answer is a guess. The spin is real -- rotation_deg ramps 0 -> 360 + # with position, scale and alpha all constant -- and measuring the two + # oracle captures says the game never shows 0: the same sprite sits at + # ~76 deg with NEW GAME focused and ~210 deg with OPTIONS focused, + # 134 deg apart at peak correlation 0.97 against a null control of 0.37 + # (docs/port/DECISIONS.md, "the focus ring IS drawn rotated"). + # + # What is missing is the PERIOD, and it has two unknowns, both the + # Decoder's: the ramp's second keyframe is untimed, and "groups hold" + # predicts a stop at 360 = 0, which is not what either capture shows. + # Holding at 0 is the pose that invents nothing; a spin rate would be + # invented. See docs/port/BLOCKED.md. var pose: Dictionary = fe.get("rest", {}) var pivot := _vec(fe.get("pivot", [0, 0])) var pos := _vec(pose.get("pos", [0, 0]))