port: P6 -- the menu has sound, and the BGM I "chose" was decoded all along

The three Static.slb cues and the menu bed now export to Ogg Vorbis and play.
`sylpheed_formats::media` does the assembly; nothing in port/ has heard of XMA.

Three things this milestone got wrong before it got right, all recorded in
docs/port/DECISIONS.md because the corrections are the useful part:

1. The cue offsets were a Rust `const` in the exporter. They are MEASURED, not
   decoded -- a measured value compiled into the exporter is a measurement
   wearing the costume of a decoded field, and nobody deletes it because nobody
   can see it. They are authored/audio.json now.

2. I picked BGM_001 and wrote a careful `why` calling the choice arbitrary. The
   menu's music is BGM_103, and it is in HANDOFF at 9ca1eb5 -- the exact commit
   BLOCKED.md says that row was reconciled against. Not stale: wrong when
   written. I had summarised a negative without its reach, so "the TABLES cannot
   say which BGM a screen plays" became "it is not on the disc". One word of
   scope was the whole answer, and the export failed only because BGM_001
   without its .slb extension hashes to nothing. That is luck, not design.

3. The comment above the BGM sum argued for unity gain "because halving is a mix
   decision nobody made". It clipped at +1.8 dBFS. 1/n is the smallest constant
   that provably cannot clip -- the same reasoning video.rs already carried for
   its 5.1 downmix, in this repository, unread.

Unsettled and shipped as such: media::sound_bank_riffs returns THREE sub-waves
for BGM_103.slb where HANDOFF Q10's census says exactly two (the third is the
leading headerless region slb.rs emits for the voice path). The exporter sums all
three and writes a manifest warning, because which bytes belong together is the
decoders' question, not this exporter's -- and dropping one would destroy the
evidence, since a corrected export looks exactly like a correct one. Raised with
the Decoder; row in BLOCKED.md.

The gate is a null control, not a peak reading. A master-bus WAV that is
non-silent proves nothing -- the bed alone would look identical. So the same
scripted walk was run with <- in place of <v>, which fires no cue (Q5, measured),
and the difference is one 0.55 s burst at t=1.10 s and silence everywhere else.
The first attempt at that control returned bit-identical zero and I nearly filed
it as "cues never reach the bus": both runs ended at 1.115 s and the first press
lands at 1.17 s. A null result from an instrument that was not running is not a
null result.

Refutation attempt: HANDOFF Q8's three cue durations. They looked attackable --
0.133/0.172/0.169 s per packet, no shared rate -- but an XMA1 packet carries a
variable number of 512-sample frames, and the three come to 50.0/32.3/95.3
frames. Measured off the decoded Ogg: 0.533, 0.344, 1.016 s, every published
digit. SURVIVES, with its reach stated -- it confirms the assembly path and my
transcription, not the event bindings, which only an oracle can retake.

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

View File

@@ -78,6 +78,13 @@ rec.set_recording_active(false)
rec.get_recording().save_to_wav("user://master.wav")
```
**This is implemented.** `godot --path port -- --menu … --audio=/tmp/p6.wav`
installs the effect, records for the whole run, and saves on exit — in
`_exit_tree` rather than beside each `quit()`, because there are eight of those
and the one that would get missed is an error path, i.e. exactly the run whose
audio somebody wants to look at. The run prints the driver name beside the file
it wrote.
Then feed that WAV through §1 against the source. That closes the loop: it
proves the asset is right **and** that the engine reached it, which no amount of
file comparison can show on its own.
@@ -115,3 +122,19 @@ That it *sounds right*. Every method here shows correspondence to a source, not
that the source is the audio the game plays at that moment, and not that levels
are sane in a mix. A ten-second human listen still answers something no
measurement above does — so when a result rests on one of these, say which one.
## 4. What the exporter checks, so nobody has to remember to
`sylpheed-export` measures **peak level and duration** of every audio file it
writes and records both in `manifest.json`; `sylpheed-export check` refuses a
tree whose peak is ≤ −90 dBFS (silent) or ≥ 0 dBFS (clipping).
Those are content checks in a format validator on purpose. Silence is the failure
this page opens by naming — right duration, right channel count, right size, full
of zeroes — and every structural check passes it. Clipping is the other one, and
the BGM can produce it, because a music bank is two stems summed at unity gain
(HANDOFF Q10).
⚠️ Neither says the audio is the **right** audio. `docs/port/BLOCKED.md` says
which bindings are measured and which are still authored, and no measurement on
this page can move a row there.

View File

@@ -10,12 +10,25 @@ for, so it is deleted rather than forgotten when the answer arrives.
## Provenance of this page
🟢 **The oracle is live again (2026-08-29).** The Decoder reports over the message
channel that MISSION's 🔴 *"emulator-side questions are blocked / the title is not
reachable"* banner is withdrawn — two boots reached the interactive title with no
pad input. **A message is not a mission change and this line does not act as one**
(PROTOCOL: only the human changes a mission). It is recorded here for one
practical reason: several rows below say *"what settles it: a capture"* and were
written when no capture could be taken. Those are now askable.
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.
**Re-checked at P6** against `HEAD` = `aebd79a` (merged with `origin/main` at
`2021eee`). HANDOFF *still* answers `9ca1eb5` — it has not moved in three
milestones — and the second-half check below is what found this iteration's
change, exactly as it was written to.
🔴 **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
@@ -62,13 +75,16 @@ git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconc
| 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.** |
| ~~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. **✅ TAKEN at P6, 2026-08-29.** The three offsets now live in `authored/audio.json` `se.*` — *not* in the exporter — because MISSION §3 puts a measured value in `authored/` and a measured offset compiled into a Rust `const` is a measurement wearing the costume of a decoded field. `sylpheed_formats::media::se_wave_riff` does the assembly. |
| ~~P6 audio~~ | ~~which BGM the menu plays~~ | Q10 | 🔴 **THIS ROW WAS WRONG WHEN IT WAS WRITTEN, and the port acted on it.** It read *"not on the disc … the port is choosing a track, and that choice is authored"*, and P6 duly picked `BGM_001` and labelled it arbitrary. **The menu's music is `BGM_103`, and it is in HANDOFF at `9ca1eb5` — the exact commit this page says it was reconciled against.** Not stale: misread. HANDOFF's negative is bounded and the bound is the whole content of it — the *tables* (`SOUNDS`, `FILES`, bank headers) name no screen; `GamePart_Title`'s `sub_821C5580` carries `li r5, 1103`, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. HANDOFF's own sentence: *"The port does not have to choose a track."* **The lesson is not "re-read HANDOFF" — this page's own staleness check passed.** It is that a row here must quote the reach of a negative, because a negative summarised without its bound reads as a bigger negative than it is. |
| P6 looping | where a menu loop restarts | Q10 | ❔ **still open, and the port shipped the ugly answer on purpose.** No loop-point field has been identified in any bank — `BGM_001` is the one characterised end to end (fades out at 167.663 s into 6.15 s of silence) and nothing suggests `BGM_103` differs in kind. `authored/audio.json` sets `loop: "restart"` — replay from sample 0 — so the listener hears the fade-out and the trailing silence at the seam. **Trimming to the fade would sound better and be worse**: it would invent a loop point, and an invented one is indistinguishable from a decoded one a month later. What settles it: a loop-point field, or a capture of the real menu looping. |
| ~~P5 focus marker~~ | ~~the focus ring's spin PERIOD, and whether it loops~~ | Q1 + *"groups hold"* | ✅ **answered 2026-08-29, and NOT ON `main` YET.** The Decoder pointed at it over the message channel and the pointer resolves: branch `auto/no-disc-and-menu-captures`, commit **`4fa3099`** (branch head `66e74d4`), file `docs/re/focus-ring-spin-measured.md`, frames under `docs/re/captures/focus-ring/`. **The ring spins continuously — period 2.177 s wall-clock, eight evenly spaced autocorrelation peaks over nine revolutions**, with no angle estimated anywhere (both angle estimators failed their own controls and were not used). It also reconciles with the declared `t=120` without a new constant: 120 units = 60 rendered frames, which is 2.00 s at a true 30 Hz and 2.08–2.17 s at the 27.6–28.8 fps this emulator runs, so the measurement sits at the top of the predicted band. 🟡 The Decoder is explicit that this is *consistency, not closure* — the guest frame rate was not measured in the same run. ⚠️ **Do not read `captures/focus-ring/ring-20s-mean-uniform.png` as a frame**: the spin averages to a uniform circle, which is the finding, not a headless ring. **The port has not implemented this yet** — it still draws 0°, which the same corpus says is a pose the game never shows. That is next iteration's work and it is no longer blocked. |
| P3/P5 — the title screen | **does the idle post-boot title show the `PRESS Ⓐ` plate?** | Q2 | 🔴 the port's boot ends on `title` (build 4), which has **no plate**, and P5 has just made Ⓐ the only way off it. Both states are captured — `live-title-build4-no-plate.png` and `live-title-press-a.png` — so the art is not the question; the **sequence** is: build 4 alone, build 4 with build 2 over it, or build 4 *then* the plate after a delay. Behavioural, so the port has no oracle for it. `press_start` (build 2) is already exported and unused. ⚠️ Fixing it also means drawing **two builds at once**, which this port has never done — a change to `ScreenView`, not a line in `flow.json`. Not blocking P5. |
| 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. |
| P6 BGM — the sub-wave count | **is a music bank's LEADING REGION a stem, or a decoder artefact?** | Q10 | 🔴 **HANDOFF and the decoders disagree, and P6 ships the disagreement.** `media::sound_bank_riffs("BGM_103.slb")` returns **three** sub-waves; HANDOFF Q10's census says a music bank is *"exactly two waves of identical duration (32/32 banks on the disc)"*. The third comes from `slb.rs:380` `to_xma_riffs`, whose hybrid branch emits a leading headerless packet region ahead of the `RIFF` waves — and `docs/re/REFUTED.md` already records that region as what makes `BGM_106`–`BGM_109` *"break the two-wave rule"*. Derived at HANDOFF `9ca1eb5`. **The exporter sums all three and writes a manifest warning**, because choosing which sub-wave to drop is a decoding question and MISSION §2 forbids this exporter answering one. So the menu currently plays a sum of three things where the census predicts two. What settles it: whether that leading region carries music. Raised with the Decoder 2026-08-29. |
| P3/P5 — the title plate | ~~does the idle title show `PRESS Ⓐ`~~ | Q2 | ✅ **answered 2026-08-29, NOT ON `main` YET, and it is the expensive answer.** Pointer from the Decoder, unverified by me at the time of writing: branch `auto/no-disc-and-menu-captures` at **`fb536df`**, `docs/re/title-plate-delay-measured.md`, per-frame traces in `docs/re/data/plate-timing-run{1,2}.tsv`. It confirms the 🔴 row below: `ScreenView` **does** have to draw two builds at once, and `--boot`'s plate-free end state is wrong. ⚠️ The Decoder flags a wrong way to read the number — time it from the frame build 4 *stops animating*, not from the frame it first appears; the two runs differ by half a second on the latter and 6 ms on the former. **Read the file, not this row.** Not taken at P6; this is the next iteration's work. |
## Answered since this file was last written — no longer blocking
Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s

View File

@@ -1219,3 +1219,375 @@ than fixing, for two reasons:
Filed in `BLOCKED.md`. Not blocking: P5's gate is Ⓐ into a submenu and Ⓑ back,
and both work.
## P6 — menu audio, 2026-08-29
The disc's menu sound reaches Godot as Ogg Vorbis: three cues and one music bed.
Nothing in `port/` has heard of XMA, `sound.pak` or `Static.slb`, and nothing in
it reassembles anything — `sylpheed_formats::media` does that and the exporter
converts what it hands back.
### The cue offsets moved OUT of the exporter, into `authored/`
The previous iteration left `crates/sylpheed-export/src/audio.rs` holding the
three `Static.slb` offsets as a Rust `const CUES`. That is wrong under MISSION
§3 and the fix is the first thing this iteration did.
Those offsets are **measured**, not decoded. `Static.slb` has no `RIFF`, no seek
chunk and no container: it is a packed run of whole 2048-byte XMA1 packets, and a
wave is defined *only* by `(offset, packet_count)`. Both numbers came from the
running game — Canary with `--xma_param_probe=true` prints a stream's packet
count and first 32 bytes when it is played, and searching those bytes in the bank
gives the offset (HANDOFF Q8).
A measured value compiled into the exporter is **a measurement wearing the
costume of a decoded field**. It reads as though the exporter derived it from the
disc; nobody deletes it when the real answer lands, because nobody can see that
there is anything to delete. So the table is `authored/audio.json` `se.*`, each
row carrying its own `why`, and the exporter holds no cue table at all.
`crate::video::MOVIES` stays a `const` in the exporter, and the contrast is the
point: Q9 **decoded** that mapping off the movie manifest on the disc. Same
shape, different provenance, different home.
### `name_match` is a field, and its absence means something
Q8 names `SE_UI_CURSOR` for the move cue by **name match against the authors' own
identifiers** — a plausible guess, not the measurement. For Ⓐ, Q8 is explicit
that the wave was *not* separated between `SE_UI_DECIDE` and
`SE_UI_SUB_WIN_OPN`, so no name is claimed at all.
`name_match` therefore travels beside every cue in `authored/audio.json` and in
`manifest.json`, and **an absent one means nobody claimed a name — never that
the binding is unknown.** The binding is the measured part. Collapsing the two
would turn "we did not separate two candidates" into "we do not know what this
sound is", which is a different and much weaker statement than the one the RE
agent actually made.
### The BGM is NOT a choice, and this port spent an iteration believing it was
The first draft of `authored/audio.json` picked `BGM_001`, wrote a careful `why`
explaining that the choice was arbitrary, and was **wrong**.
`docs/port/BLOCKED.md` carried the row that caused it: *"not on the disc … the
port is choosing a track, and that choice is authored."* The menu's music is
**`BGM_103`**, and it is in HANDOFF at **`9ca1eb5`** — the exact commit that page
says it was reconciled against. So this was not staleness. **The row was wrong
when it was written.**
What HANDOFF says is a negative *with a bound*, and the bound is the entire
content of it:
> the **tables** cannot say — `SOUNDS`, `FILES` and the bank headers name no
> screen. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`
> into a sound call; cue 1103 is `BGM_103`; and `BGM_103.slb`'s two declared
> waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe
> saw decoding at the main menu. Static code, disc census and runtime all agree.
> **"The port does not have to choose a track."**
The failure is worth naming precisely, because "read HANDOFF more carefully" is
not the lesson — `BLOCKED.md`'s own staleness check passed, twice, and would pass
again. **A negative summarised without its reach reads as a bigger negative than
it is.** "The tables cannot say" became "it is not on the disc", and one word of
scope was the whole answer. A row in `BLOCKED.md` must quote the reach.
It also cost a second thing worth recording: the port would have shipped a menu
playing the wrong music with a confident `why` beside it saying the choice was
deliberate. That is exactly the shape of error this project's vocabulary exists
to prevent, produced *by* the machinery meant to prevent it.
### The bank name carries `.slb`, and that is how the mistake surfaced
`BGM_001` is not in `sound.pak`. `BGM_001.slb` is — `media::read_sound_bank`
looks up `name_hash(name)` against the TOC, and the TOC hashes the **file name**.
`Static.slb` worked from the first run only because the RE finding happens to
write it with its extension.
So the wrong track never played: the export failed loudly with *"BGM_001: not
present in sound.pak"*. That is luck, not design — had the draft picked a name
that happened to resolve, nothing would have complained. The `why` in
`authored/audio.json` now records both the correct name and why the short form
fails.
`export_bgm` now distinguishes the two cases it was conflating. A bank that is
**not in this disc's `sound.pak`** is a missing asset: the manifest takes a
warning and everything else still exports. Any other failure — a short read, a
malformed bank — still stops the run, because a partly-read bank produces a file
that plays.
### The two stems are summed. That part is not a choice
Q10 also measured that a bank's sub-waves are **two stems of one performance,
played together** — sample-synchronous, equal duration, on all 32 banks.
Concatenating them is explicitly wrong.
Emitting them as two files would be wrong for a second, independent reason:
MODDING rule 1 is *one logical asset, one file*, and handing a modder two stems
to line up by hand is precisely the reassembly the exporter exists to have
already done. `amix=normalize=0` sums at unity rather than halving, because
halving is a mix decision nobody made — and because a sum can clip, the peak is
**measured and reported** rather than silently corrected.
### The loop seam is ugly on purpose
No loop-point field has been identified. `loop: "restart"` replays from sample 0,
so a listener hears the track's own fade-out and its trailing silence before the
music comes back.
Trimming to the fade would sound better and would be **worse**. It would invent a
loop point, and an invented one is indistinguishable from a decoded one a month
later — which is the failure mode this whole project is organised against. The
seam stays audible until a loop point is measured or a capture of the real menu
looping settles it.
### When a cue fires — two rules measured, one authored
* **Move** fires on a press that *actually moves the cursor*. `MenuFlow.move()`
already returned whether it did, which is why left/right stay silent by
construction rather than by a rule written twice (Q5: ⬅➡ do nothing, and Q8:
they play nothing).
* **Ⓐ and Ⓑ** fire when the press *does something*, and not when nothing is
bound. 🟡 **This half is authored and NOT measured** — nobody has watched the
game take a dead press. Silence invents less: a sound the game does not make is
a wrong fact you can hear, while a missing one is a gap. `blocked` counts as
doing something, because those destinations *were* measured off the running
game and are missing from this export, not from the game.
* The bed starts when the menu becomes live and **carries across submenus**.
`play_bed` is idempotent, because music that restarts every time you press Ⓑ is
the kind of wrong that reads as "the audio works".
### `--audio=` records the Master bus, because neither container has a sound card
`docs/port/AUDIO-VERIFICATION.md` §2. An `AudioEffectRecord` on the Master bus
captures the mixed output from inside a headless run with no device at all, and
that is the only thing that closes the loop the file opens: comparing an exported
Ogg against the disc proves the **asset** is right and says nothing about whether
the engine ever reached it.
The run prints `AudioServer.get_driver_name()` beside the file it wrote, because
"recorded under a dummy driver" is a weaker claim than "heard" and the write-up
has to be able to say which one it is making.
The WAV is saved in `_exit_tree` rather than beside each `quit()`. There are
eight of those, and the one that would get missed is an error path — exactly the
run whose audio somebody wants to look at.
### `check` now refuses silence and clipping
`sylpheed-export check` gained an `audio` pass, and two of its rules are content
checks rather than schema checks. That is deliberate. Silence is *the* audio
failure that looks like success — a file of the right duration, the right channel
count and the right size, full of zeroes — and it passes every structural check
there is. Clipping is the other one, and the BGM can produce it because it is a
sum at unity gain. The exporter measures both at export time; `check` refuses a
tree whose peak is ≤ −90 dBFS or ≥ 0 dBFS.
Neither is a judgement about whether the audio is the *right* audio. Nothing in
that binary can know that, and `BLOCKED.md` says which parts are still authored
guesses.
### A bug worth naming: the temp name ate the file extension
`run_ffmpeg` wrote to `.back.ogg.partial` — the temp-name-then-rename discipline
this project uses everywhere, and which `AUDIO-VERIFICATION.md` records as
already having caused a confident wrong number once.
ffmpeg picks its muxer **from the output filename**, so that is not a slightly
uglier temp name; it is a hard failure before a byte is written: *"Unable to
choose an output format for '.back.ogg.partial'"*. `video.rs` already had the
right shape (`.ADV.partial.ogv`) and this function was written from scratch
without looking at it. The extension goes last.
### Refutation — the three Q8 cue durations, checked end to end
**The claim:** HANDOFF Q8 publishes three cue lengths — move **0.533 s**
(8 192 B, 4 packets), back **0.344 s** (4 096 B, 2), confirm **1.016 s**
(12 288 B, 6). P6 is built directly on top of these, which by PROTOCOL's own rule
makes them the right thing to attack: refutation is cheapest where the other
agent is most confident, and most valuable where the port is about to build.
**Why they looked attackable.** The three do not share a rate. Seconds per
packet is 0.133, 0.172 and 0.169 — the move cue is 22 % off the other two. If a
packet were a fixed span of audio, at most one of these numbers could be right.
**Why that is not a refutation.** An XMA1 packet is 2 048 bytes of *bitstream*,
not a fixed span: it carries a variable number of 512-sample frames. At 48 kHz a
frame is 10.667 ms, and the three durations come to **50.0, 32.3 and 95.3
frames** — near-integers, which is what a variable-frames-per-packet encoding
looks like and is not what an arithmetic slip looks like.
**The measurement.** The exporter reads `(offset, packet_count)` through
`media::se_wave_riff`, decodes, and `ffprobe`s the finished Ogg:
| cue | Q8 claims | exported file measures |
|---|---|---|
| move | 0.533 s | **0.533 s** |
| back | 0.344 s | **0.344 s** |
| confirm | 1.016 s | **1.016 s** |
**Verdict: survives, exactly, at every published digit.** Recorded as a survival
rather than a pass, because that is what PROTOCOL asks for — a claim that has
survived an attempt is stronger than one nobody challenged, and the corpus should
say which it is.
⚠️ **Reach, stated so nobody over-reads it.** This is not independent of Q8: the
durations were derived from the same packet counts the exporter feeds in, so what
it confirms is that reading those `(offset, packets)` through
`sylpheed_formats::media` yields streams of exactly the claimed length — i.e.
that the *transcription* into `authored/audio.json` and the assembly path are
right. It does **not** confirm that these three waves are the sounds the game
plays on those three events; that is Q8's own measurement, taken by playing them,
and this port has no oracle to re-take it with.
The attempt did find something, just not here: see the BGM section above, where
the port's *own* `BLOCKED.md` row failed the same kind of check.
### The BGM bank has three sub-waves and HANDOFF says it has two
`media::sound_bank_riffs("BGM_103.slb")` returns **three**. HANDOFF Q10's census
says a music bank is *"exactly two waves of identical duration (32/32 banks on
the disc)"* — and that census is itself a correction, of an earlier reading that
called `BGM_001` three sub-waves and was refuted with "the 10 KB is the bank
header".
The third comes from `sylpheed-formats/src/slb.rs:380`, `to_xma_riffs`: when a
bank has a leading headerless packet region ahead of its first `RIFF`, that
region is emitted as a sub-wave. It exists because the voice path needs it —
`VOICE_D_453` decoded to 0.14 s without it. `docs/re/REFUTED.md` already records
the same region as what makes `BGM_106`–`BGM_109` "break the two-wave rule".
**The port sums all three and says so in the manifest.** That is not the
appealing answer — dropping sub-wave 0 would give a file matching the census, and
it would have been one line. It is the correct one: *which bytes belong together*
is the question `sylpheed_formats::media` owns, MISSION §2 names re-deriving it
here as the single easiest thing in this project to get subtly wrong, and "the
decoder returned something the corpus does not predict" is a finding to report,
not a number to quietly adjust. Adjusting it would also have destroyed the
evidence: a corrected export looks exactly like a correct one.
So the export ships the decoders' answer, the manifest carries a warning naming
the contradiction, `BLOCKED.md` has the row, and the Decoder has the pointer.
Until it comes back, **the menu plays a sum of three things where the census
predicts two**, and every one of those places says so.
### Clipping — and a comment of mine that argued for the thing that clipped
The BGM came out at **+1.8 dBFS**. The comment above the code that produced it
said `amix=normalize=0` sums at unity "because halving is a mix decision nobody
made".
That was wrong in both halves. Unity summing *is* a decision, and it is the one
that clips. And 1/n is not a taste call: it is the smallest constant that makes
an n-input sum of unity-scale signals provably clip-free, which is precisely the
reasoning `video.rs` already carried for its 0.4142-normalised 5.1 downmix — in
this same repository, written by this same port, and not looked at. It preserves
the stems' relative balance exactly, which is the only thing about the sum that
Q10 settles.
It is written as an explicit `volume=` rather than left to `amix`'s
`normalize=1` default, so the coefficient appears in the manifest's command line.
A default is a decision nobody made and it can move under an ffmpeg upgrade —
the same argument MISSION §6 makes about the downmix matrix.
**The `confirm` cue is a different case and is not "fixed".** It lands at
+0.18 dBFS, and it is a single wave off the disc with no arithmetic of ours in
it: the disc masters it near full scale and a lossy decode of a near-full-scale
signal overshoots by a fraction of a dB. Attenuating it would mean altering a
game asset to make one of our own numbers smaller. So `check` bounds the two
kinds differently — a `bgm` peak ≥ 0 dBFS is refused outright, because it is our
sum; an `se` is refused only above **+1.0 dBFS**.
🟡 That +1.0 is a **judgement and not a measurement**, and it is the weakest
number in P6. Nobody has measured the overshoot distribution across a corpus of
cues. If a cue ever trips it, the right response is that measurement, not a
looser bound.
## P6 gate — the audio is in the mix, and a null control says which part
No container here has a sound card, so "P6 works" cannot be answered by
listening. `docs/port/AUDIO-VERIFICATION.md` splits the question into three, and
these are the two that need no device.
### 1. The exported files, measured off the finished assets
```
se back -> audio/se/back.ogg (0.344 s, peak -5.7 dBFS)
se confirm -> audio/se/confirm.ogg (1.016 s, peak +0.2 dBFS)
se move -> audio/se/move.ogg (0.533 s, peak -1.4 dBFS)
bgm main_menu -> audio/bgm/main_menu.ogg
(87.744 s, peak -7.7 dBFS, bank BGM_103.slb, 3 sub-waves)
```
`sylpheed-export check export` passes: 16 screens validate, and every audio entry
carries a peak and a duration inside its bounds. The three cue durations match
HANDOFF Q8 at every published digit — see the refutation record above.
### 2. The engine, recorded off the Master bus
```
godot --path port -- --menu --script=down,down,accept,cancel --audio=…/p6.wav
→ recorded 6.037 s of Master bus (driver Dummy)
peak 0.0 dBFS, RMS −21.1 dBFS
```
**Non-silent is not the claim.** A WAV of the right duration full of the *bed*
would look exactly like this, and the cues could be missing entirely. So the cue
was isolated with a **null control**: the same scripted walk with ⬅ in place of ⬇.
Left/right are measured no-ops (Q5) and fire nothing, so the two runs differ by
exactly two move cues and nothing else — same screens, same transitions, the same
Ⓐ and Ⓑ cues in both, the same bed.
| | RMS |
|---|---|
| walk with two ⬇ presses | −21.9 dBFS |
| walk with two ⬅ presses (null) | −22.1 dBFS |
| **difference** | **−34.6 dBFS** |
The difference is not spread over the run. It is **one burst beginning at
t = 1.10 s and lasting 0.55 s** — two overlapping 0.533 s move cues — with 22 of
237 windows above −70 dBFS and silence everywhere else, including across the Ⓐ
and Ⓑ presses, which cancel because both runs make them. That is the cue reaching
the bus, separated from the music that was playing over it.
### The control that proved nothing, kept because it nearly passed
The first attempt paired `--script=down,down` against `--script=left,left`. The
difference was **bit-identical zero**, which reads as "the cues never reached the
bus" and would have been reported as a bug.
It was neither. Both runs recorded **1.115 s** while the first press lands at
~1.17 s: the control ended before the event it was controlling for. A null result
from an instrument that was not running is not a null result — PROTOCOL's "run
your own instrument through a control" applies to the control too.
### What this does NOT establish
* **That it sounds right.** Everything above is correspondence and separation,
not judgement. A ten-second human listen still answers something no measurement
here does.
* **That the bed is at a sane level against the cues.** 🔴 The Master bus peaks at
**0.0 dBFS** in the four-step run — the `confirm` cue is +0.2 dBFS on its own,
so any music under it puts the mix on the ceiling. Per-file levels are the
disc's and are fine; the **runtime** mix has no headroom. The port has not set a
bus balance, because nothing measures one and an invented balance is the same
class of mistake as an invented loop point. Recorded here rather than fixed
quietly.
* **That "Dummy driver" means heard.** It does not, and the run prints the driver
name so a write-up cannot forget to say so.
### One bug, in two dialects, both about a temp filename
The temp-name-then-rename discipline this project uses everywhere broke twice in
this milestone, in two different tools, for the same underlying reason: **tools
dispatch on the extension, so a temp name must preserve it.**
* `run_ffmpeg` wrote `.back.ogg.partial` → *"Unable to choose an output format"*,
a hard failure before a byte was written.
* `boot.gd` wrote `p6.wav.part` → `save_to_wav` **appends** `.wav` when the path
does not end in it, producing `p6.wav.part.wav`; the rename then failed to find
its source, its return value was not checked, and the run printed a success
line naming a file that did not exist.
The second is the more dangerous shape, and it is the one this project has
already warned itself about: a confident line of output pointing at nothing. The
rename's return is now checked and the failure is loud.

View File

@@ -309,13 +309,42 @@ reaches which entry is Q4 and is not).
"disc": "/disc",
"screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json",
"sprites": 18, "missing_sprites": [] }],
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
"videos": [{ "name": "ADV", "file": "video/ADV.ogv",
"command": "ffmpeg -i …", "why": "HANDOFF Q9: …" }],
"audio": [{ "kind": "se", "name": "move", "file": "audio/se/move.ogg",
"command": "ffmpeg -i …", "why": "HANDOFF Q8, measured: …",
"peak_dbfs": -3.2, "duration_s": 0.533,
"name_match": "SE_UI_CURSOR" },
{ "kind": "bgm", "name": "main_menu", "file": "audio/bgm/main_menu.ogg",
"command": "ffmpeg -i …", "why": "AUTHORED, an arbitrary choice: …",
"peak_dbfs": -1.1, "duration_s": 173.8, "loop_mode": "restart" }],
"warnings": ["GP_READY_ROOM not exported -- out of scope"]
}
```
`video_transcode` will record the exact command so a modder can re-run it rather
than reverse-engineer what was done. It is absent until P4 writes a video.
`videos` and `audio` are **absent** until a milestone writes one, rather than
present and empty: an empty array reads as "we looked and there is none", and
that is not what an export taken before P4 or P6 means.
### `command` and `why`, on every media entry
`command` is the exact ffmpeg invocation that produced the file. MISSION §6: a
modder who dislikes the quality re-runs one line rather than reverse-engineering
what was done to their asset. `why` is where the value came from, in the
project's three-way vocabulary — **decoded** off the disc, **measured** off the
running game, or **chosen**. A `why` that does not say which of those it is has
not done its job.
### `audio`, field by field
| field | |
|---|---|
| `kind` | `se` or `bgm`. The runtime dispatches on it, so it is a field rather than a prefix on `name` that a consumer would have to parse |
| `name` | the **role**, not the disc asset: `move`, `confirm`, `back`, `main_menu`. Which bank plays a role is authored and expected to change; a rename on the disc side must not be a change to the Godot project |
| `peak_dbfs` | measured off the finished file. **Required.** Silence is the audio failure that looks like success — right duration, right channel count, right size, full of zeroes — and clipping is the other one, which the BGM can produce because it is a sum of two stems at unity gain. `sylpheed-export check` refuses a tree whose peak is ≤ −90 dBFS or ≥ 0 dBFS |
| `duration_s` | measured off the finished file, so that a claim about a cue's length can be checked against the finding that produced it |
| `name_match` | the game's own cue identifier **guessed by name**. Absent means nobody claimed one — never that the binding is unknown. The binding is the measured part; the name is not |
| `loop_mode` | what the runtime does at the end of the file, where that was authored. Absent on a cue: a cue ends |
## Changes from v2