parse_riff_wave read every fmt chunk as a WAVEFORMATEX. XMA1 (tag 0x0165) is not one, so audio info reported the disc s movie voices as 16 channels, 4310 Hz, 2-bit: 16 is wBitsPerSample read as a channel count and 4310 is wEncodeOptions (0x10d6) read as a sample rate. This misled me earlier in the session and I recorded it as a limitation before finding the cause. XMA1 carries XMAWAVEFORMAT followed by one XMASTREAMFORMAT per stream. The reader now branches on the tag and reads bits at +2, PsuedoBytesPerSec at +12, SampleRate at +16 and Channels at +29. The same three files now report 2 channels, 48000 Hz, 16-bit. The consequence worth having: this crate has no XMA decoder, and data_bytes / PsuedoBytesPerSec is the only route to a duration. Checked against durations decoded independently by the port: ADV presentation 1 137.34 s declared 137.324 s decoded +0.012 percent ADV presentation 2 137.33 s declared 137.324 s decoded +0.004 percent S00A presentation 1 93.71 s declared 93.694 s decoded +0.017 percent So the corpus can now get XMA1 durations off the disc without a decoder, which is a capability I had written down as absent. It is a declared rate rather than a measurement of the samples, and the CLI labels it as such. Regression test pins the real on-disc header bytes and asserts the duration against the independently decoded 137.324 s. 115 lib tests and 3 media disc tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
364 lines
18 KiB
Markdown
364 lines
18 KiB
Markdown
# 🟡 A movie-voice region's THIRD chunk is not the bank-header case — and nothing else claims it
|
||
|
||
**Status:** ✅ the *structure* is decoded, disc-wide, 95/95 regions. 🟡 what the
|
||
leading chunk **contains** is open, and this page states the reach of that
|
||
negative rather than guessing. 🔴 One claim this page carried — that chunks 1 and
|
||
2 are two stems of one performance — is **withdrawn**; see
|
||
[the bottom of the page](#what-a-consumer-should-do-meanwhile).
|
||
|
||
Raised by the port: `media::sound_bank_riffs("BGM_103.slb")` used to return three
|
||
sub-waves where [`bgm-two-stems`](bgm-two-stems.md) says two, and
|
||
[`slb-bank-header-not-a-wave`](slb-bank-header-not-a-wave.md) attributed the
|
||
extra to the **bank header**. The port then hit *the same 2+1 signature on a
|
||
different asset kind* — a resolved movie-voice region also decoding to three
|
||
chunks — and asked whether one explanation covers both.
|
||
|
||
**It does not.** They are two different structures, and the corpus's own code
|
||
already tells them apart; what it does not do is say which one it is looking at.
|
||
|
||
Tool: `cargo run -p sylpheed-formats --example voice_region_chunks -- $SYLPHEED_DISC`.
|
||
Census committed at [`data/voice-region-chunk-census.txt`](../data/voice-region-chunk-census.txt).
|
||
|
||
## ✅ Disc-wide: a voice region never begins at a `RIFF`
|
||
|
||
All 95 English movie-voice regions the manifest binds:
|
||
|
||
| how the region opens | regions | chunks it yields |
|
||
|---|---|---|
|
||
| a **bank header** — `bank_header_len` fires, **10 240 B = 5 packets exactly**, every time | **78** | 1 (×70) or 3 (×8) |
|
||
| a **leading headerless stream** — `bank_header_len` is `None` | **17** | **3, every time** |
|
||
| directly at a `RIFF` | **0** | — |
|
||
|
||
And the leading streams are not ragged. **All 17 have a length ≡ 1392 (mod
|
||
2048)** — no other residue occurs — which is exactly `HEADERLESS_DATA_OFFSET`,
|
||
the `<lang>\etc\` data offset that [`slb-data-offset`](slb-data-offset.md)
|
||
derives. So a leading stream is `1392 B` of preamble followed by a whole number
|
||
of 2048-byte XMA1 packets: 394 of them on `ADV`, 646 on `S00A`, 900 on `S12C`.
|
||
|
||
That is the discriminator the port needed, and it is mechanical:
|
||
|
||
```
|
||
bank_header_len(region) == Some(n) -> n is 10240, a header, already consumed
|
||
bank_header_len(region) == None -> first_riff % 2048 == 1392, a real stream
|
||
```
|
||
|
||
## 🔴 So the BGM explanation does not transfer
|
||
|
||
`slb.rs`'s own doc comment predicted this and disagrees with "drop it": the
|
||
header signature fires on 28 `sound.pak` entries, all music banks, with *"zero
|
||
false positives on the 7 993 mid-bank windows, **where the leading region IS
|
||
real**"*. A movie-voice region is a mid-bank window by construction —
|
||
`resolve_movie_voice_region` anchors its start at the **predecessor cue's
|
||
trailer**, deliberately, because the cue may sit either side of its own `.slb`
|
||
chunk.
|
||
|
||
⚠️ **The 3-chunk count is not evidence of the leading region at all.** Eight
|
||
regions open with a bank header *and still yield three chunks* (`S11A`, `S12A`,
|
||
`S12B`, `S13B`, `S15B`, …). Counting chunks cannot distinguish the two cases;
|
||
only `bank_header_len` can.
|
||
|
||
## 🔴 "It is the previous cue's audio, so dropping it is right" — TESTED, and it fails
|
||
|
||
The obvious defence of dropping the leading chunk is that the region starts at
|
||
the predecessor's trailer, so those bytes are the previous line of dialogue.
|
||
That is checkable without decoding anything: take each leading span
|
||
`[start, start + first_riff)` and ask whether any *other* resolved region covers
|
||
it.
|
||
|
||
| | |
|
||
|---|---|
|
||
| leading spans lying wholly or partly inside another movie-voice region | **0 of 17** |
|
||
| …expressed as covered fraction | **0.0 % on every one** |
|
||
|
||
For contrast, the regions themselves are not disjoint — 16 overlapping pairs, 60
|
||
exactly-adjacent boundaries, 18 gaps — so the test is capable of finding an
|
||
overlap, and it finds none here. **73 of 78** bank-header regions start exactly
|
||
where another region ends; **0 of 17** leading-stream regions do.
|
||
|
||
So the leading chunk is not another *movie's* voice.
|
||
|
||
## ✅ RESOLVED 2026-08-29 — the leading chunk is the MOVIE'S OWN cue, and the mechanism is a guard
|
||
|
||
The section that stood here left this open and named an in-mission `VOICE_D_*`
|
||
line as the leading hypothesis. **That hypothesis is refuted.** The port pointed
|
||
out that the byte-span test already built settles it without anyone listening, if
|
||
the enumeration is widened past the 95 manifest-bound movies — and it does.
|
||
|
||
Rather than resolving cues one at a time, scan the stream for **every** trailer
|
||
descriptor: the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
|
||
[`movie_voice`](../../crates/sylpheed-formats/src/movie_voice.rs) documents as
|
||
the end of a cue's audio, with a false-match probability of ~2⁻⁶⁴. The full
|
||
descriptor list **is** the stream's complete cue partition, movie and mission
|
||
alike. Over a 116.2 MB window covering every region: **287 descriptors, and all
|
||
287 carry an id the registry names** (4 280 cue names).
|
||
|
||
Tool: `cargo run -p sylpheed-formats --example voice_stream_cue_map -- $SYLPHEED_DISC`,
|
||
output at [`data/voice-stream-cue-map.txt`](../data/voice-stream-cue-map.txt).
|
||
|
||
### The leading span belongs to the movie itself — 17 of 17
|
||
|
||
Each leading span is bracketed by `desc(N-1) .. desc(N)`, and in every case
|
||
`desc(N)` is **that movie's own cue id**:
|
||
|
||
| movie | leading span ends at descriptor | |
|
||
|---|---|---|
|
||
| `ADV` | id 1600 = `VOICE_ADV` | movie cue |
|
||
| `S00A` | 1501 = `VOICE_S00A` | movie cue |
|
||
| `S14A` | 1524 = `VOICE_S14A` | movie cue |
|
||
| …all 17 | | **movie cue, 0 mission lines** |
|
||
|
||
By the stream's own rule — cue N's audio is `[desc(N-1) .. desc(N)]` — those
|
||
bytes are **this movie's dialogue**. 🔴 So "it is an in-mission `VOICE_D_*` line"
|
||
is dead, and so is any reading in which the leading chunk is foreign audio.
|
||
|
||
### ✅ And the mechanism is `resolve_movie_voice_region`'s own guard
|
||
|
||
`resolve_movie_voice_region` takes the predecessor trailer as the region start,
|
||
but guards it with `end - start < 1_500_000` and falls back to the `.slb` TOC
|
||
**anchor** when that fails. If the guard is the cause, the stream-opening regions
|
||
should be exactly the cues whose true span exceeds it:
|
||
|
||
| | cues | of which stream-opening |
|
||
|---|---|---|
|
||
| true cue span **≥ 1.5 MB** | **17** | **17** |
|
||
| true cue span **< 1.5 MB** | **78** | **0** |
|
||
|
||
**Perfect discrimination, both ways.** A long cue's region does not start at its
|
||
cue boundary; it starts at the anchor, mid-cue, and everything from the anchor to
|
||
the next `.slb` `RIFF` becomes the leading chunk. That is the whole phenomenon.
|
||
|
||
⚠️ **The anchor sits a constant `504 464 B` after the true predecessor trailer on
|
||
all 17** — not an approximate constant, the same number every time. That
|
||
regularity is unexplained and is worth someone's attention; it says the `.slb`
|
||
chunk boundary is placed at a fixed distance from a trailer.
|
||
|
||
### ✅ RESOLVED 2026-08-29 (later) — a long cue is stored as THREE presentations of one take
|
||
|
||
The section that stood here declined to convert bytes into seconds and left open
|
||
*why one cue's byte span decodes to ~2.6× the movie*. That is now answered, by
|
||
two independent routes that agree.
|
||
|
||
**The port's route — envelope cross-correlation, with controls.** Sliding with
|
||
overhang at both ends and normalised over the overlap only:
|
||
|
||
| | best r | at lag | overlap |
|
||
|---|---|---|---|
|
||
| `ADV` chunk 0 → chunk 1 | **0.998** | +52.8 s | 84.5 s |
|
||
| `S00A` chunk 0 → chunk 1 | **0.932** | +25.6 s | 68.0 s |
|
||
| control — `ADV` chunk 0 → itself | 1.000 | 0.0 s | — |
|
||
| control — `ADV` chunk 0 → `S00A` chunk 1 | **0.289** | — | 28.2 s |
|
||
|
||
Both lags put chunk 0 flush against the **end** of chunk 1 (52.8 + 84.55 =
|
||
137.35 against 137.324; 25.6 + 68.07 = 93.67 against 93.694). In the sample
|
||
domain, after refining the lag and best-fitting a scalar, the residual is
|
||
**16.70 dB** below the target over 84.5 s (`ADV`) and 23.15 dB over 68.1 s
|
||
(`S00A`) — 98–99.5 % of the energy is a scaled copy.
|
||
|
||
⚠️ Their earlier 0.768 is **withdrawn by them**: that search only tried lags where
|
||
the shorter chunk fitted wholly inside the longer, and scored best on the
|
||
boundary of its own range — where a statistic lands when it has found nothing.
|
||
|
||
**My route — byte rates, from the disc, with no decoder.** If the leading chunk
|
||
is the tail of a *full-length* first stream, then the whole leading stream
|
||
(the part before the region's anchor plus the part after) should be one complete
|
||
take of chunk 1's duration. Using only the port's durations and the disc's byte
|
||
counts:
|
||
|
||
| | |
|
||
|---|---|
|
||
| full leading stream, `ADV` | 504 464 + 808 304 = **1 312 768 B** |
|
||
| its byte rate, from chunk 0 | 808 304 B / 84.553 s = **9 559.7 B/s** |
|
||
| implied duration of the whole leading stream | **137.323 s** |
|
||
| chunk 1's measured duration | **137.324 s** |
|
||
|
||
**Agreement to 1 ms over 137 s**, from a quantity (byte rate) independent of the
|
||
one the port measured (envelope correlation). And it confirms their point that
|
||
bytes-per-second is not a constant: the three streams run at **9 560 / 8 143 /
|
||
8 531 B/s** for the same 137.324 s.
|
||
|
||
### ✅ And the byte structure says three, disc-wide
|
||
|
||
The reading above predicts a fixed number of stream starts inside a cue's **true**
|
||
span `[desc(N-1) .. desc(N)]`. Counted directly over every inter-descriptor span
|
||
in the 116.2 MB window — RIFFs, plus a leading headerless run when it is not a
|
||
bank header:
|
||
|
||
| streams in the span | spans |
|
||
|---|---|
|
||
| **1** | **258** |
|
||
| **3** | **28** |
|
||
| 2, or anything else | **0** |
|
||
|
||
**Bimodal, with nothing in between**, and *all 20* spans ≥ 1.5 MB are 3-stream.
|
||
So a cue is stored as **one** stream or as **three**, never two.
|
||
|
||
That closes the account, and the three routes agree on the arithmetic:
|
||
|
||
| the 95 movie regions | cue shape | what the region yields |
|
||
|---|---|---|
|
||
| **70** | 1-stream | 1 chunk, bank header |
|
||
| **8** | 3-stream, span < 1.5 MB | 3 chunks, bank header |
|
||
| **17** | 3-stream, span ≥ 1.5 MB — the guard fires | 3 chunks, headerless leading one |
|
||
|
||
70 + 8 + 17 = 95, and the 8 here are independently the same 8 the first census
|
||
found as "bank header, 3 chunks". `359 s = 84.55 + 137.32 + 137.32` — the region
|
||
catches the tail of stream 1 and the whole of streams 2 and 3. **The 2.6× is
|
||
three presentations of one take, one of them clipped by our own guard.**
|
||
|
||
⚠️ **The 504 464 B constant is structural, not proportional.** It is the same
|
||
number on all 17 despite their differing durations. For `ADV` a proportional
|
||
prediction lands within 8 bytes of it, which is a coincidence and should not be
|
||
built on — the same prediction for `S00A` is 4 305 B out.
|
||
|
||
## What a consumer should do meanwhile
|
||
|
||
✅ **Drop the leading chunk. It is a DUPLICATE, not a truncation.** It is the
|
||
cutscene's own dialogue — but it is the *tail of the stream that follows it*, so
|
||
removing it loses nothing any other chunk does not carry. An earlier version of
|
||
this page said "I would not change the exporter yet"; that hedge is lifted, and
|
||
the reason the exporter's existing behaviour is right is now on the record.
|
||
|
||
🔴 **But DO NOT sum chunk 1 and chunk 2 — they are the same take, not two
|
||
stems.** `ADV` chunk 2 is `0.60 ×` chunk 1 with the residual 26.8 dB down;
|
||
`S00A` chunk 2 is digital silence. Summing a take with a scaled copy of itself
|
||
adds ~4 dB and colours it; summing it with silence at `1/n` costs 6.02 dB. **Take
|
||
one stream.**
|
||
|
||
### 🔴 Which stream — my recommendation was self-contradictory, and is withdrawn
|
||
|
||
This page said *"the highest-rate, highest-gain one is chunk 1"*. **Those two
|
||
criteria do not select the same stream, and the sentence should never have joined
|
||
them.** On `ADV`, chunk 1 is 1 118 268 B at **0.0 dBFS** and chunk 2 is
|
||
1 171 516 B at **−8.3 dBFS**: chunk 2 has the higher rate and the *lower* level.
|
||
The port implemented "highest rate", correctly, and thereby selected the quieter
|
||
presentation — the opposite of what the parenthetical intended. My error, and the
|
||
port caught it by checking the consequence rather than the instruction.
|
||
|
||
**What the header does decode.** The `fmt ` chunk is a 32-byte `XMAWAVEFORMAT`,
|
||
little-endian, and `+0x20` is `PsuedoBytesPerSec` — a **declared** field, not an
|
||
inference:
|
||
|
||
| | `ADV` chunk 1 | `ADV` chunk 2 | `S00A` chunk 1 |
|
||
|---|---|---|---|
|
||
| `+0x20` declared bytes/sec | **8 142** | **8 530** | **13 485** |
|
||
| computed from size ÷ duration | 8 143.3 | 8 531.0 | 13 487.3 |
|
||
| `+0x24` sample rate | 48 000 | 48 000 | 48 000 |
|
||
| `+0x18` `wEncodeOptions` | `0x10d6` | `0x10d6` | `0x10d6` |
|
||
| `+0x31/+0x32` channels / mask | 2 / `0x0002` | 2 / `0x0002` | 2 / `0x0002` |
|
||
|
||
✅ So the **rate is decoded** and agrees with the measured one to ~0.02 %.
|
||
❔ **But nothing in the header ranks the presentations.** `wEncodeOptions`,
|
||
channel count and channel mask are byte-identical across them. The header says
|
||
how fast each stream is, and says nothing about which one the game plays.
|
||
|
||
🟡 **So stream selection is an authored choice, and the port must know it is
|
||
authoring.** It is flagged in the port's manifest with the level consequence
|
||
stated, which is the right handling. **This is settleable in one emulator run** —
|
||
a capture of the intro with the dialogue audible tells you which level the game
|
||
plays — and it has not been done.
|
||
|
||
❔ *Why* the disc stores three presentations — quality tiers, a mix the engine
|
||
selects between, an authoring artefact — is not answered here.
|
||
|
||
### 🔴 Refutation attempt, 2026-08-29 — "the extra bytes are a duplicated channel" does NOT generalise
|
||
|
||
The port selected a presentation on this argument: `ADV` chunk 1 is
|
||
**mono-in-stereo** (channel 2 digitally silent) and chunk 2 is **dual-mono**
|
||
(both channels identical at −8.318574), so chunk 2's extra bytes encode a
|
||
duplicate of its own channel rather than fidelity — which would explain its
|
||
higher declared `PsuedoBytesPerSec` without appealing to encode quality.
|
||
|
||
**The `ADV` measurement is theirs and stands. The generalisation does not.** If
|
||
stream 3 were systematically "the same take with its channel duplicated", its
|
||
size would sit in a tight ratio to stream 2 on every 3-stream cue. Measured over
|
||
all 28 — [`data/voice-three-stream-sizes.txt`](../data/voice-three-stream-sizes.txt),
|
||
`--example voice_three_stream_sizes`:
|
||
|
||
| stream3 / stream2 | |
|
||
|---|---|
|
||
| min | **0.0778** (`S00A`, the silent one) |
|
||
| median | 1.2565 |
|
||
| max | **2.9163** (`S06A`) |
|
||
| sd | **0.5057** |
|
||
| within 15 % of 1.0 | **12 of 28** |
|
||
|
||
**A 37× spread is not a duplicated channel.** The declared rates scatter with
|
||
them — `S06A` is 5 661 against 16 513 B/s, `S00A` 13 485 against 1 049 — so
|
||
whatever distinguishes the three streams varies per cue rather than being a fixed
|
||
channel-configuration triple.
|
||
|
||
⚠️ **Two curiosities worth someone's time:** `S12B`'s three streams are
|
||
**byte-size identical** (14 396 each), and `S11A`'s first two are (81 980). And
|
||
`BIRD_224` is 3-stream while being a non-movie cue, so the 3-stream shape is not
|
||
exclusive to cutscenes.
|
||
|
||
✅ **What this does and does not touch.** It does **not** touch the port's
|
||
decision, which is to take the **loudest** presentation — that is a per-asset
|
||
content measurement, not a structural rule, so a scattering ratio cannot
|
||
undermine it. What it touches is the *explanation*: "more bytes means a
|
||
duplicated channel, not better fidelity" is true of `ADV` and is **not** a fact
|
||
about the format. It should not harden into one.
|
||
|
||
### ✅ FIXED 2026-08-29 — and the disc will now tell you a duration without a decoder
|
||
|
||
`sylpheed-cli audio info` used to report these chunks as *16 channels, 4310 Hz,
|
||
2-bit*. The cause: `parse_riff_wave` read every `fmt ` chunk as a
|
||
`WAVEFORMATEX`, and **XMA1 is not one**. 16 is `wBitsPerSample` read as a channel
|
||
count; 4310 is `wEncodeOptions` (`0x10d6`) read as a sample rate.
|
||
|
||
XMA1 carries `XMAWAVEFORMAT` followed by one `XMASTREAMFORMAT` per stream, and
|
||
the reader now branches on the tag. Same three files:
|
||
|
||
```
|
||
Channels : 2 Sample rate: 48000 Hz Bit depth : 16-bit
|
||
Byte rate : 8142 B/s (declared)
|
||
Duration : 137.34 s (from the declared byte rate, not decoded)
|
||
```
|
||
|
||
✅ **The duration is the part that matters, because this crate has no XMA
|
||
decoder.** `data_bytes / PsuedoBytesPerSec` is the only route to one, and it was
|
||
checked against durations the port decoded independently:
|
||
|
||
| stream | declared-rate duration | independently decoded | error |
|
||
|---|---|---|---|
|
||
| `ADV` presentation 1 | 137.34 s | 137.324 s | **+0.012 %** |
|
||
| `ADV` presentation 2 | 137.33 s | 137.324 s | **+0.004 %** |
|
||
| `S00A` presentation 1 | 93.71 s | 93.694 s | **+0.017 %** |
|
||
|
||
⚠️ It is a *declared* rate, so this is the file's own claim about itself rather
|
||
than a measurement of the samples — but on the three streams where an independent
|
||
decode exists, the claim is accurate to 0.02 %. Regression test
|
||
`xma1_fmt_is_not_a_waveformatex` pins the real on-disc header bytes.
|
||
|
||
⚠️ **Retroactive note:** several statements earlier in this session said this
|
||
container could not obtain a duration for these streams. That was true of the
|
||
decoder and *not* of the file, which had been declaring it at `fmt +0x20` the
|
||
whole time. The tool was misreading it, and a broken tool reported as a missing
|
||
capability is worth more than the fix.
|
||
|
||
⚠️ **Do not "fix" it by concatenating.** The port measured a concatenated region
|
||
at 359 s against a 137 s movie.
|
||
|
||
🔴 **But the REASON this page gave was wrong, and is withdrawn (2026-08-29).**
|
||
It said chunks 1 and 2 are "the two-stem pattern [`bgm-two-stems`](bgm-two-stems.md)
|
||
documents for music — equal duration, played together". That claim originated
|
||
with the port, I adopted it here on the strength of equal duration, and the port
|
||
then refuted its own claim by decoding the content:
|
||
|
||
* **`S00A` chunk 2 is digital silence** — 4 497 300 samples, peak −∞. Not a quiet
|
||
stem. Nothing at all.
|
||
* **`ADV` chunk 2 is `0.60 ×` chunk 1** — best-fit scalar, residual **26.8 dB
|
||
below** the target. ~95 % of its energy is a −4.4 dB copy of chunk 1, not an
|
||
independent performance.
|
||
|
||
Equal duration was a *shape* match and Q10's music census should not have been
|
||
carried across to voice on it. ⚠️ **This is how a wrong belief hardens**: it was
|
||
asserted in one place, adopted in a second, and the second citing the first would
|
||
have made it look corroborated. It was caught because the port measured its own
|
||
claim rather than the other agent's.
|
||
|
||
❔ **What `ADV`'s near-duplicate chunk 2 is remains open** — a decoding question,
|
||
not a port one. What is *not* open is that summing a digitally silent chunk at
|
||
`1/n` costs 6.02 dB for nothing; the port drops silent chunks before summing,
|
||
which is arithmetic rather than a content judgement.
|