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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user