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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey
This commit is contained in:
Sylpheed port agent
2026-08-29 11:27:05 +00:00
parent 60595d4062
commit eef45ecfd6
7 changed files with 808 additions and 28 deletions

View File

@@ -43,13 +43,126 @@
"When a capture times the real boot, the extra hold per screen goes here." "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": { "screens": {
"_": [ "_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", "What each button does. The NAVIGATION ORDER is not here -- it is derived,",
"destination screens and the RE agent later decoded that a transition is a", "in each screen file's `buttons` (button-role elements sorted by resting Y).",
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,", "Only the destinations, the initial focus and the cancel target are",
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`", "authored, because only those are measurements or decisions.",
"targets when this is written, marked as the name match they are." "",
] "`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."
}
}
}
} }
} }

View File

@@ -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 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, 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`. 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 ⚠️ **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 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 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 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. | | 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 ~810 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 ## 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 | | Authored because it is not on the disc | HANDOFF | Where it lives |
|---|---|---| |---|---|---|
| `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 | | `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.170.23 s black hold | Q7 | not yet written — P3 | | the ~0.4 s fade-out and the 0.170.23 s black hold | Q7 | not yet written — P3 |
## The five asks — four answered at `9ca1eb5` ## The five asks — four answered at `9ca1eb5`

View File

@@ -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, 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 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. 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
~810 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.

View File

@@ -10,6 +10,16 @@
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png # godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
# godot --path port -- --boot # the whole boot sequence # 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 -- --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 # `--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 # 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: func _ready() -> void:
var args := _args() 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() var export_tree := ExportTree.locate()
if export_tree.root == "": if export_tree.root == "":
push_error(export_tree.error) push_error(export_tree.error)
@@ -39,17 +53,32 @@ func _ready() -> void:
return return
_flow = export_tree.authored("flow.json") _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 args.has("boot"):
if _flow == null:
push_error(export_tree.error)
get_tree().quit(2)
return
for step: Dictionary in _flow["boot"]: for step: Dictionary in _flow["boot"]:
_sequence.append(step) _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() \ 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 == "": if name == "":
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
var screen: Dictionary = export_tree.screen(name) var screen: Dictionary = export_tree.screen(name)
@@ -99,6 +128,10 @@ func _ready() -> void:
get_tree().quit(2) get_tree().quit(2)
return 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() var settle := view.settle_time()
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [ 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(), name, view.screen["elements"].size(), view.screen["paint_order"].size(),
@@ -120,6 +153,12 @@ func _ready() -> void:
var _frozen := false var _frozen := false
var _flow: Variant = null 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 _sequence: Array[Dictionary] = []
var _player: VideoStreamPlayer = null var _player: VideoStreamPlayer = null
var _step := 0 var _step := 0
@@ -137,7 +176,18 @@ func _process(delta: float) -> void:
_elapsed += delta _elapsed += delta
view.queue_redraw() 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 return
# A screen holds at `rest` until it has arrived, then plays itself out and # 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: elif not _boot_done:
_boot_done = true _boot_done = true
print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]]) 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) get_tree().quit(0)
elif not view.holding and view.time_units >= view.exit_time(): elif not view.holding and view.time_units >= view.exit_time():
_advance() _advance()
@@ -221,14 +277,120 @@ func _video_finished() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached # 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 # at 57 s against a 193 s baseline.
# so far; menu navigation is P5. if _player != null:
if _player == null or not _skippable: 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 return
if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"): if _menu == null or _menu.stack.is_empty():
print(" video skipped at %.2f s" % _elapsed) return
_player.stop() # AUTHORED, not measured: a press during a screen's fade-out is dropped.
_video_finished() # `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: func _capture(path: String) -> void:
@@ -271,3 +433,101 @@ static func _args() -> Dictionary:
elif arg.begins_with("--"): elif arg.begins_with("--"):
out[arg.substr(2)] = "1" out[arg.substr(2)] = "1"
return out 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])

165
port/scripts/menu_flow.gd Normal file
View File

@@ -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": <screen>, "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}

View File

@@ -0,0 +1 @@
uid://dyqb3b450d21x

View File

@@ -326,12 +326,21 @@ func _draw_focus(element: Dictionary) -> void:
if tex == null: if tex == null:
skipped.append("%s (focus sprite failed to load)" % fe.get("id", "")) skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
continue continue
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360 # The ring's rest pose, which is rotation_deg 0.
# with position, scale and alpha all constant -- but the PERIOD is not #
# established: the ramp's second keyframe is untimed, and what an untimed # ⚠️ THIS IS KNOWN TO BE WRONG, and is drawn anyway because the right
# keyframe means inside a leaf (rather than at screen level, where it is # answer is a guess. The spin is real -- rotation_deg ramps 0 -> 360
# the exit) is untested. So this holds the resting angle and does not # with position, scale and alpha all constant -- and measuring the two
# invent a spin rate. # 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 pose: Dictionary = fe.get("rest", {})
var pivot := _vec(fe.get("pivot", [0, 0])) var pivot := _vec(fe.get("pivot", [0, 0]))
var pos := _vec(pose.get("pos", [0, 0])) var pos := _vec(pose.get("pos", [0, 0]))