Files
Sylpheed/docs/port/BLOCKED.md
Sylpheed port agent eef45ecfd6 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
2026-08-29 11:27:05 +00:00

347 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Waiting on the RE agent
What this port cannot do until an answer lands in HANDOFF.md. Recorded so it is
not re-discovered every iteration.
**None of these may be guessed.** A value invented here is indistinguishable from
a decoded one a month from now. Where a milestone can proceed with a placeholder,
the placeholder goes in `authored/` with a `why` naming the question it stands in
for, so it is deleted rather than forgotten when the answer arrives.
## Provenance of this page
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
that unresolvable, and both are worth stating because the next iteration will
otherwise re-derive them:
* **The repositories were merged into one monorepo** (`65cefa7`, *"monorepo: one
repository for the decoders, the port and the corpus"*). HANDOFF.md is no
longer in a separate `Syplheed-Reborn` repo reached over a mount — it is
`docs/port/HANDOFF.md` **in this repository**, and its provenance is an
ordinary commit sha in this history. A sha from the old repo cannot be looked
up here at all.
* **The `/reborn` mount is now an empty directory.** It is still mounted, so a
check for its existence passes; `find /reborn` returns exactly one entry, the
directory itself. Anything that reads `/reborn/docs/...` fails with `No such
file or directory`, not with a mount error. Do not read it. Read the in-repo
copy and cite its sha.
Because the sha is now in-repo, this page's staleness is checkable in one
command rather than by trusting the date:
```sh
git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconcile
```
## Still open — these block work
| Milestone | Needs | HANDOFF | State |
|---|---|---|---|
| ~~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
Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s
*measured*), Q2 (which build is which screen), Q3 (paint order — a `u16` layer key
at `+0x0A`, **decoded**), Q5 (navigation: ⬆⬇ wrap, ⬅➡ nothing, Ⓑ up with focus
restored), Q7 (transitions: a fade through black, fade-in decoded, ~0.4 s fade-out
measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS00A` →
`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** —
do not concatenate), S1 (Ready Room: no-go).
**Cleared at `9ca1eb5`, and previously listed above as blocking:**
| Was blocking | HANDOFF | What the answer is |
|---|---|---|
| P5 — what Ⓐ on `NEW GAME` opens | Q4 | 🔴 **the old row was wrong, not merely stale.** It said "❔ untested: Ⓐ on it **hangs the emulator**". Q4 now reads **measured** for all **5** buttons: `NEW GAME` → `DIFFICULTY` → `SELECT DATA`, and the row says explicitly *"not a hang"*. ⚠️ The **GamePart id** behind those names is still a name match, so `flow.json` cites the destination as measured and the id as a name match. P5 is not blocked here. |
| P4/P7 — whether Ⓐ skips a movie | Q9 | ✅ **one Ⓐ skips a movie** — title reached at 57 s against a 193 s baseline. P4 already took this (`DECISIONS.md`, *"Ⓐ skips, because Q9 measured it"*); the row survived here only because nobody deleted it. |
| P3 — what drives the boot sequence | Q6 | ✅ answered, and the answer is a **negative with a stated reach**: the driver is **code, not data**, with four search spaces closed. That is not "unsettled" — it is the RE agent saying the port must author the sequence, which P3 did. Filed as answered so it stops reading like an open question. |
Also newly available, and useful to P3/P5 when they author the flow: the title
part's transitions are a **lookup by name**, and the game's own screen
vocabulary includes `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`, `DIFFICULTY`,
`EXTRA_MENU`, `TUTORIAL_MENU`. Three of those are corroborated by measurements
taken before the function was opened (`DIFFICULTY` is what `NEW GAME` opens,
`EXTRA_MENU` is `EXTRAS`, `TUTORIAL_MENU` the lesson list). 🟡 **Candidate, not
decoded** — the RE agent is explicit that the strings are what the call sites
*reference*, not proven arguments, and the same list mixes in `TEXT_FONT` and
`GAMMA_RGB`. So `authored/flow.json` may use these as `goto` names — which is
better than inventing names — but must mark them as a name match, not a
measurement.
Three of those are **measured**, not decoded, and so are authored here rather
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/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`
Sent to the RE agent 2026-08-29 and answered the same day. Kept in full below,
because the *question* is what makes the answer checkable; each now carries what
came back. **Only ask 4 is still open, and it is with the human, not the RE
agent.**
| Ask | For | State at `9ca1eb5` |
|---|---|---|
| 1 — how to recognise the splash | P3 | ✅ answered, and the answer is **"no content rule exists"** — design size and element count both fail. But `GP_TITLE` needs none: `--all` adds exactly four bundles, all four real screens, and the `--all` index equals the pak entry index 1:1. 🔴 **It also found a screen the port did not have**: entries 10/13 are the **SQUARE ENIX** publisher wordmark, the *first* thing the boot shows. |
| 2 — is the 0.4 s fade-out the whole ramp | P3 | ✅ **(a)** — one authored constant (~0.4 s / ~24 units), and **play the group to its end on every element**. (c) was refuted by a null test: a black quad alone holds the button÷background ratio constant, and the capture falls 6.50 → 1.94. |
| 3 — focus drawn OVER the base, or INSTEAD of it | P5 | ✅ **the port's choice is fine and was not the bug.** The focused sprite covers the base at 100.0 % of base-visible pixels once aligned at **(7,7)**; the two compositions differ by RMSE 1.1 inside the button rect. 🔴 **The real miss is the focus record's SECOND element** — `ptbtneff01.t32`, a 42×46 glowing ring. That is the ring marker. **This is what P5 builds on; see the refutation below.** |
| 4 — should the port draw rotation | P2/P3 | 🟡 **open, and with the human** — the RE agent declined to decide it alone. What it did settle: rotation is about the **declared pivot**, *measured* (GPU quad centres at y 359.1/360.0 against the pivot formula's 360.0; top-left predicts 810/990). ⚠️ It changes nothing on the five screens **at rest**. |
| 5 — is the oracle capture gamma-correct | all | ✅ **not gamma-neutral: RMSE against it has a floor.** `capture ≈ 255·(render/255)^γ`, γ ≈ **1.49** (main menu, `EXTRAS`), **1.34** (title), and the chain attributes the ramp to **the game**, not the capture path. ⚠️ **Reach: measured only on dark flat patches (render ~0–60)** — nothing constrains midtones or highlights. **Do not chase RMSE below the floor.** |
### Ask 4 is the only one that needs anything from anybody
It is a *joint* decision, not an RE question, and the port has said it will carry
`rotation_deg` in the format either way. Nothing in P5 touches it.
## The original five asks, as sent
Ordered by what it costs the port, not by what it costs to answer. Preserved
verbatim; see the table above for what came back.
### 1. How should the exporter recognise the developer-logo splash? (P3, blocking)
The splash is the **first thing P3 draws** and it is not in `export/`. It
declares its sprites directly and has no `.rat` layout child, so `is_build`
rejects it; `sylpheed-cli` reaches it only via `--all`, which the CLI's own help
says **renumbers `--build`**. So the port cannot address it by build index
without the index meaning something different from everywhere else in this
format.
What I need is a **predicate**, not an index: something the exporter can apply to
say "this bundle is a composable screen" that admits the splash and does not
admit the 1 894 two-element fragments `--all` also lets in. If the honest answer
is "there is no such rule, take `GP_TITLE` entries 11/14", that is a usable
answer — I will export it under a synthetic name with `name_source` saying it was
located by entry index and not by a rule.
### 2. Is the ~0.4 s fade-out the whole ramp, or a segment of it? (P3, blocking)
Q7 measures the screen fade-out at ~0.4 s and the black hold at 0.17–0.23 s.
The port needs to know **which quantity that 0.4 s is**, because the last
keyframe of a group carries no `t` and the port refuses to invent one:
* the ramp from the hold to the exit pose — i.e. the missing duration of that
final untimed keyframe; or
* hold → exit → fully black, the 0.4 s covering several keyframes; or
* something the game does independently of the group.
Under the first reading the port writes one authored constant and plays the
group to its end. Under the third it must not.
### 3. Focus: drawn OVER the base element, or INSTEAD of it? (P5, cheap, avoid rework)
`sylpheed-cli --focus` is documented as drawing the focused record **over** its
base. The port **replaces** the sprite. Those are different operations and the
port picked its one without evidence.
Evidence that the port is wrong: rendering `main_menu` with `ptbtn01` focused —
which is how `main-menu-oracle.png` was taken — makes the RMSE against that
capture **worse**, 5.92 % → 7.00 %. The capture also shows a **ring marker**
beside `NEW GAME` that the port draws nowhere. Cheap to answer from a capture
that already exists, and it decides how P5 is built.
### 4. Rotation — should the port draw it, and about what? (P2/P3, needs a joint decision)
`67fa1a1` decodes `rotation_deg` at keyframe `+12` and explicitly does **not**
render it: `ui_layout::blit` is axis-aligned. `ptloop01`/`ptloop02` on the title
declare +30° and −45°, and the framebuffer submits them at +30.26 and −45.28.
A canvas rotation is a few lines in Godot, so the port *can* draw these. But
then the port is deliberately more correct than the reference renderer, and
`verify-screen` — the port's whole verification method — starts reporting a large
diff on the title that means "the port is right". That is a bad state to be in
silently, so I would rather agree it than do it.
Two sub-questions: **is the rotation about the declared pivot** or about the
element's centre or corner? And would you rather `blit` grow a rotating path so
the diff stays meaningful? The format would go to **v3** to carry
`rotation_deg`; that is my side and I will do it either way, since carrying a
decoded field the renderer ignores is better than dropping it.
### 5. Is `main-menu-oracle.png` gamma-correct? (not blocking, but it calibrates everything)
With the background in, the port sits at 5.92 % RMSE against that capture and is
visibly **darker and less saturated** than it across the whole frame. If the
capture path applies a gamma or a colour transform the game does not, then RMSE
against captures has a floor and the port should stop chasing it. If it does
not, something is still missing. The port cannot tell these apart from inside.
## Questions this port has raised
### ~~Does a keyframe group loop, or hold its last pose?~~ — answered
**Answered 2026-08-28 by the RE agent: groups hold.** `ptloop01`/`ptloop02` park
their sprites at x=1521 and x=−839, both off a 1280-wide design, and 18 s of
settled title sits at sd ≤ 0.01. `loop*.rat` is a misleading name — these
animate once during build-in and then rest off-screen.
The port's own error here was different and is fixed: it settled at the last
*timed* keyframe rather than at the hold. See `docs/DECISIONS.md`.
Kept for the record:
Raised at P2 and **unsettled**. The port holds the last timed keyframe, which is
right for an entry animation (the main menu settles at t=80, 1.33 s) and is
proven on the screen P2 gates. The **title** runs to t=269 — 4.48 s — and there
the port's settled pose and the decoders' `rest` disagree badly (max 142/255).
What is known: no element's alpha reverses direction anywhere in this export, so
nothing pulses, which removes the obvious reason to expect a loop without
disproving one. What would settle it: **a capture of build 4 alone**. The one
live title capture composites the `PRESS Ⓐ` plate (build 2) over it, so it
cannot be diffed against the title by itself.
⚠️ Independently, **both** of the port's modes draw a washed-out cyan glow over
the title logo that the running game does not have. That is a third problem and
it is P3's; it is noted here so nobody reads the loop question as its cause.
Not blocking anything today; raised because the port found them and a guess here
would be believed later.
### ~~`rest_plateau` misfires on elements with no exit animation~~ — fixed
**Fixed 2026-08-28** in `sylpheed-formats`, and this port's pin moved
`8b6dbcf → 5414db3` to take it. The rule adopted is **not** the condition this
port proposed, which was too loose: a trailing run is the hold exactly when it
is **visible**. The port's condition would have erased the word PAUSE on
`pgptitle.rat`, whose trailing run is two identical *transparent* frames.
Kept for the record, since the reasoning is still what found it:
**This one is a decoder bug, not a question**, and it was the highest-value item
on this page for the RE agent. `ui_layout::rest_plateau` excludes a run of
identical keyframes that ends the group, on the grounds that it is the exit. For
an element that **has no exit animation** the trailing run *is* the hold, and the
rule falls back to an earlier run — for a slide-in, the invisible pre-roll.
The condition that identifies the affected elements exactly, with no false
positives across this export, is: **the final untimed keyframe has the same pose
as the last timed one.** Six elements match; `rest()` misses all six.
`ptframe1` and `ptframe2` on the main menu are the visible case, and
`docs/re/captures/main-menu-oracle.png` settles it — the game draws the circuit
bracket that `rest` calls invisible. `sylpheed-cli screen render` is missing it
too, so this is not only a port concern.
The port needs nothing here: it derives the arrived pose from the keyframes and
does not use `rest`. Filed because `rest()` is used elsewhere and because a
capture already proves it.
### Does the game sample a scaled sprite at the pixel corner or the pixel centre?
Found at P1, by the only screen it could have been found on. `title_jp`'s
`ptlogo_eff2` is the **single drawn element in the whole export** at a scale that
is not a whole multiple of 100 % (125 %), and `title_jp` is the only one of the
twelve screens whose Godot-vs-CLI diff exceeds 6/255.
The two renderers pick different source texels at a non-integer ratio.
`sylpheed_formats::ui_layout::blit` samples at the destination pixel's **top-left
corner** (`sxi = col * sw / dw`); a GPU samples at its **centre**
(`floor((col+0.5)*sw/dw)`). At 125 % they disagree on one column in five — ~30
pixels above 100/255, strung along thin diagonal edges. At every whole multiple
of 100 % they agree exactly, which is why the other eleven screens are clean.
The port has **not** changed to match: matching would mean reproducing a half-
pixel bias on purpose to make a number smaller. The question for the RE agent,
when it is cheap: **a framebuffer capture of the Japanese title screen** would
settle it outright, and it is the kind of thing a capture answers in one look.
Cost of being wrong either way: a one-texel edge on one glow, on a screen the
English boot path never shows. This is filed, not urgent.
### The pivot is not half the texture on `GP_TITLE`
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
element this is exactly half the decoded texture's dimensions (verified 7/7 on
the tutorial bundle)". Counting it over the whole of `GP_TITLE` as exported:
* **55 of 93** sprite-bearing `.t32` elements match within ±1 px.
* **38 do not**, and several are not close: `ptlogo_back2` is 1118×262 with pivot
(500, 117) where half is (559, 131); `ptmsg` is 223×38 with pivot (123, 19)
where half is (111.5, 19) — the Y matches and the X does not.
This changes nothing today: the exporter emits the **declared** pivot and never
derives one, and the pivot only affects drawing when scale ≠ 100 %. But it does
matter, because scale is genuinely animated here — **177 keyframes** across
`GP_TITLE` are not 100 %, including on the title screen the port must draw at P1.
The question for the RE agent, when it is cheap to answer: **does the running
game anchor a scale to the declared pivot, or to half the texture?** The two
differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port
follows the decoders and uses the declared pivot, which is also what
`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
agreement between the two is not evidence.
**P1 has now been run and that prediction held.** The port and the CLI agree on
every scaled element across all twelve screens; the question is untouched by it.
It will stay untouched by P2 as well, since P2 animates the same two renderers'
shared assumption. Only a capture answers this.
### Does the focus ring spin while a button is focused? (P5)
Raised 2026-08-29 against HANDOFF `9ca1eb5`. **Not blocking** — P5 can draw the
ring at rest and say so — but it is a guess if taken either way, so it is not
taken.
`ptbtneff01` is the 42×46 glowing ring that ask 3 identified as the focus
marker's first element. In every one of the five focus records it declares
exactly two keyframes:
```
t=120 pos (500, y) scale 100% rotation_deg 0
— pos (500, y) scale 100% rotation_deg 360 (untimed, the hold)
```
A full turn, ending on the untimed final keyframe. The settled answer *"groups
hold, they do not loop"* (2026-08-28, from `ptloop01`/`ptloop02` parking
off-screen) does not decide this one, because **0° and 360° are the same pose** —
a ring that spins forever and a ring that turns once and stops are
indistinguishable by their rest pose, which is the evidence that settled the
other case. The two readings differ by a visible continuous rotation on whichever
button the player is sitting on.
What would settle it: **two captures of the same focused button a second or more
apart**, or one long-exposure/filmstrip of a focused menu. Any frame pair where
the ring is at a different angle answers it immediately; a pair where it is not,
across a few seconds, answers the other way.
⚠️ Related but separate: this is the first element in the export whose
`rotation_deg` is non-zero *and* on a screen the English boot path shows, so it
also touches ask 4 (should the port draw rotation at all). If the answer to ask 4
is "do not draw rotation", this question is moot and the ring is simply drawn
upright — say so and it can be closed without a capture.