port: withdraw my own "two stems" reading of a voice region, and stop summing silence
The Decoder asked me to decode a voice region's leading chunk -- it has no XMA1
decoder in its container -- and the decoder run refuted a claim of mine that it
had already adopted into `docs/re/structures/voice-region-leading-chunk.md`.
I wrote that a region's two equal-length chunks are HANDOFF Q10's decoded
two-stem shape. Equal duration was a SHAPE match and I carried the music census
across on the strength of it. The content does not support it:
S00A chunk 2 is DIGITAL SILENCE -- 4497300 samples, peak -inf.
ADV chunk 2 is 0.60x chunk 1, best-fit scalar, residual 26.8 dB below the
target: about 95% of its energy is a -4.4 dB copy of the first chunk.
That cost real level. Summing chunk 1 with silence at 1/n put S00A's dialogue
6.02 dB down for nothing -- the exported file peaked at -16.2 dBFS against a
source chunk peaking at -4.2. `export_voice` now drops a digitally silent chunk
before the sum, which is arithmetic and not a judgement about content.
WHAT ADV'S NEAR-DUPLICATE SECOND CHUNK IS REMAINS OPEN AND IT IS STILL SUMMED.
Whether the game plays both is a decoding question, 26.8 dB of residual is not
nothing, and dropping a chunk because it correlates with another would be
answering it.
The leading chunk, answered as far as a measurement goes: ADV region + 1392, 394
packets, 84.553 s, stereo 48 kHz, peak -2.48 dBFS, 6 silent gaps over 0.4 s
totalling 45.3 s -- 54% silence, the same duty cycle as the full-length chunks.
Speech-structured, so not a header and not padding. "Cutscene or mission" is an
identification and this agent has no ears and no oracle; envelope correlation
peaks at 0.768 at the last lag in the search range, which is where a statistic
lands when it has found nothing, and it is not an answer.
Not taken yet, and said so in BLOCKED: the discriminator should be
`bank_header_len`, not a duration tie. This exporter never used `riffs.len()`, so
it already handles both of the Decoder's cases, but a tie is an observation and
`bank_header_len` is decoded. It switches when `c1f3608` reaches `main`;
`sylpheed-formats` is a path dependency and merging another agent's topic branch
is not the port's to do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
@@ -33,7 +33,14 @@ fn main() {
|
||||
.output()
|
||||
.unwrap();
|
||||
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&w);
|
||||
if std::env::var("KEEP_WAV").is_ok() {
|
||||
let keep = std::path::Path::new(&std::env::var("KEEP_WAV").unwrap())
|
||||
.join(format!("{movie}_chunk{i}.wav"));
|
||||
let _ = std::fs::rename(&w, &keep);
|
||||
println!(" kept -> {}", keep.display());
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&w);
|
||||
}
|
||||
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
@@ -535,18 +535,43 @@ pub fn export_voice<S: DiscSource + ?Sized>(
|
||||
// Classify before mixing. XMA declares no duration, so each chunk is decoded
|
||||
// and timed -- the only way to tell a stem from the leading region, and the
|
||||
// measurement that showed concatenation to be wrong here.
|
||||
let lengths: Vec<f32> = all.iter().map(|p| decoded_seconds(p).unwrap_or(0.0)).collect();
|
||||
let longest = lengths.iter().cloned().fold(0.0f32, f32::max);
|
||||
let probed: Vec<(f32, f32)> = all.iter().map(|p| decoded_chunk(p)).collect();
|
||||
let lengths: Vec<f32> = probed.iter().map(|&(d, _)| d).collect();
|
||||
// A DIGITALLY SILENT chunk is dropped before anything else, and that is
|
||||
// arithmetic rather than a judgement about content: it contributes nothing
|
||||
// to a mix, and counting it in the 1/n normalisation costs 6.02 dB for
|
||||
// nothing. `S00A`'s second full-length chunk is exactly this -- 4 497 300
|
||||
// samples of zeroes, peak -inf -- and summing it is why that movie's voice
|
||||
// came out at -16.2 dBFS against a source peaking at -4.2.
|
||||
let silent: Vec<usize> = (0..all.len()).filter(|&i| probed[i].1 <= -90.0).collect();
|
||||
let longest = (0..all.len())
|
||||
.filter(|i| !silent.contains(i))
|
||||
.map(|i| lengths[i])
|
||||
.fold(0.0f32, f32::max);
|
||||
// A tie at 1 ms. The two stems agree to six decimals and the chunk that is
|
||||
// not one of them misses by tens of seconds, so nothing sits near this
|
||||
// bound: it separates the measured cases without being a tuned threshold.
|
||||
let keep: Vec<usize> = (0..all.len())
|
||||
.filter(|&i| (longest - lengths[i]).abs() < 0.001)
|
||||
.filter(|&i| !silent.contains(&i) && (longest - lengths[i]).abs() < 0.001)
|
||||
.collect();
|
||||
let dropped: Vec<String> = (0..all.len())
|
||||
.filter(|i| !keep.contains(i))
|
||||
.map(|i| format!("chunk {i} ({:.3} s, {} B)", lengths[i], riffs[i].len()))
|
||||
.map(|i| {
|
||||
format!(
|
||||
"chunk {i} ({:.3} s, {} B, peak {})",
|
||||
lengths[i],
|
||||
riffs[i].len(),
|
||||
if silent.contains(&i) {
|
||||
"SILENT".to_string()
|
||||
} else {
|
||||
format!("{:.1} dBFS", probed[i].1)
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if keep.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let staged: Vec<PathBuf> = keep.iter().map(|&i| all[i].clone()).collect();
|
||||
|
||||
// The fold is chosen from what the stream declares, because `pan` silently
|
||||
@@ -674,13 +699,17 @@ pub fn probe_duration(path: &Path) -> Option<f32> {
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Seconds one staged XMA `RIFF` decodes to.
|
||||
/// Seconds and peak dBFS that one staged XMA `RIFF` decodes to.
|
||||
///
|
||||
/// XMA carries no duration in its header, so this decodes the chunk to PCM and
|
||||
/// measures the result. That is expensive and it is the only instrument that can
|
||||
/// tell a stem from the leading region: `ffprobe` on the `RIFF` itself returns
|
||||
/// `N/A`, which a caller that trusted it would read as zero.
|
||||
fn decoded_seconds(riff: &Path) -> Option<f32> {
|
||||
/// XMA carries no duration in its header, so the chunk is decoded to PCM and the
|
||||
/// result measured. That is expensive and it is the only instrument that can
|
||||
/// separate these chunks at all: `ffprobe` on the `RIFF` itself returns `N/A`
|
||||
/// for duration, which a caller that trusted it would read as zero, and the
|
||||
/// corpus records `sylpheed-cli audio info` mis-reading the same headers as
|
||||
/// 16 channels at 4 310 Hz.
|
||||
///
|
||||
/// A silent chunk returns `-inf`, which the caller drops.
|
||||
fn decoded_chunk(riff: &Path) -> (f32, f32) {
|
||||
let wav = riff.with_extension("probe.wav");
|
||||
let ok = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
@@ -689,7 +718,12 @@ fn decoded_seconds(riff: &Path) -> Option<f32> {
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
let out = if ok { probe_duration(&wav) } else { None };
|
||||
let out = if ok {
|
||||
let (peak, dur) = measure(&wav);
|
||||
(dur.unwrap_or(0.0), peak.unwrap_or(f32::NEG_INFINITY))
|
||||
} else {
|
||||
(0.0, f32::NEG_INFINITY)
|
||||
};
|
||||
let _ = std::fs::remove_file(&wav);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ HANDOFF.
|
||||
|---|---|---|---|
|
||||
| P4/P7 — the intro's dialogue | ~~why the intro has no voices~~ | Q9 | ✅ **answered and TAKEN 2026-08-29, and the obvious diagnosis was wrong.** Not a transcode fault: `ADV.wmv` carries music and effects only, and a cutscene's voice is a *separate* continuous XMA stream in `sound.pak` bound by the movie manifest. `audio::export_voice` now resolves it with `media::resolve_movie_voice_region` — never by filename, because `RT01A`'s voice lives inside `VOICE_ADV.slb` and a name match is right on exactly the two movies this port would have spot-checked. Region chunks are **concatenated** (one continuous stream), not summed. This is **decoded, nothing authored**. |
|
||||
| P4/P7 — the movie downmix | **is the exporter allowed to ship a matrix MISSION §6 did not pin?** | — | 🔴 **with the HUMAN, not the Decoder, and now visible for the first time.** §6 pins the 5.1 fold as a human decision of 2026-08-29; `video.rs` has shipped that matrix scaled by **0.4142** since P4 — same weighting, **7.65 dB quieter** — and said so nowhere. Re-measured this iteration with the right instrument (float decode, whole file, count the samples that would clamp, not a peak reading): under the **pinned** matrix `ADV` peaks at **+4.26 dBFS** with **4 406** samples at or over full scale and 1 874 more than 1 dB over, while `S00A` peaks at −1.34 dBFS and **never clips**. So the pin overloads one movie and the exporter's constant is over-broad for the other. Smallest single scalar under which neither clamps: **0.612**, +3.39 dB on today. **Not changed** — the level of a mix is what §6 reserves. The export now carries a manifest warning with these numbers. |
|
||||
| P4/P7 — a voice region's chunks | **what is the leading chunk, and is `ADV`'s near-duplicate second chunk played?** | — | 🟡 **half answered by the Decoder, half by my decoder run, and one of my own claims withdrawn.** Structure is decoded disc-wide, 95/95 regions (`auto/no-disc-and-menu-captures` at `7e12a3b`, `docs/re/structures/voice-region-leading-chunk.md`): 78 regions open with a bank header, 17 with a leading headerless stream at the disc's own `1392 mod 2048` data offset, 0 at a `RIFF` — and **the chunk count discriminates nothing**, 8 header regions also yield three. What the leading chunk *contains* is 🟡, and the Decoder cannot settle it: no XMA1 decoder in that container. I decoded it — `ADV`, 84.553 s, speech-structured, 54 % silence — which says it is dialogue-shaped and says **nothing about whose dialogue**. 🔴 **And it refuted my own "two stems" reading**: `S00A`'s second chunk is digital silence and `ADV`'s is 0.60 × the first with 26.8 dB of residual. The silent one is now dropped (arithmetic — it cost `S00A` 6.02 dB); `ADV`'s near-duplicate is **still summed and still open**. What settles the leading chunk: enumerating the `VOICE_D_*` regions and re-running the Decoder's own byte-span coverage test. |
|
||||
| P4 — is an attract movie skippable at all? | **does the real game let Ⓐ end `ADV`, or does it play through?** | Q9 | 🔴 **a human play-test reports Ⓐ does not skip the port's intro, and the port could not tell which bug that is.** It is *implemented*, not assumed: `authored/flow.json` carries `skippable: true` with a `why` citing Q9 as measured (title at 57 s against a 193 s baseline), and `boot.gd` `_unhandled_input` acts on it. What did not exist was any way to **test** it: `--script` structurally cannot press during a movie, because `_script_settled` waits while `_player != null`. `--skip-at=SECONDS` was added this iteration to close that hole. ⚠️ Two different questions sit behind the one symptom, and only the first is mine: (a) does the synthetic press reach `_unhandled_input` — measurable here; (b) does the **game** permit skipping an attract movie — `INDEX.md` still marks skippability 🟡 and only a capture settles it. If (b) is no, the port's skip path is deleted rather than debugged. Asked 2026-08-29. |
|
||||
| ~~P5 — a real submenu cycle~~ | ~~is any submenu reachable without a new archive?~~ | Q2/Q4 | ✅ **already shipped at P5, and one premise of the ask is refuted by this repo.** `ptbtn05` (EXTRAS) → screen `extras` (entries 6/9), and `extras`' `on_cancel` returns to `main_menu` with focus restored — a full main-menu → submenu → back cycle, in `GP_TITLE`, live since P5. ⚠️ **Build 8 is not a submenu.** It is `main_menu_jp`, the Japanese five-button main menu; `authored/screen_names.json` records that an earlier reading called 8 a submenu and that HANDOFF Q2 **withdrew it** against a capture. That coordinates identical to build 5 mean a language twin rather than a second menu is exactly the inference the port is not allowed to make on layout similarity — in either direction. The other four main-menu items really are blocked: `GP_SAVE_LOAD`, `GP_OPTIONS`, `GP_MISSION_SELECT` and the `DIFFICULTY`/`TUTORIAL_MENU` builds are not in this archive. |
|
||||
|
||||
|
||||
@@ -2592,3 +2592,67 @@ to skip is live**. What that does not cover is a real key event from a focused
|
||||
window, which is the difference between this run and the human's — and, separately,
|
||||
**whether the game permits skipping an attract movie at all** is HANDOFF Q9 and
|
||||
still 🟡. If the answer is no, this path is deleted rather than debugged.
|
||||
|
||||
|
||||
## Refutation of my own two-stem reading — and it had already been adopted elsewhere
|
||||
|
||||
Two hours after writing that a voice region's equal-length chunks are *"HANDOFF
|
||||
Q10's decoded two-stem shape"*, the Decoder asked me to decode the leading chunk
|
||||
— it has no XMA1 decoder in its container — and the decoder run refuted the
|
||||
claim I had made.
|
||||
|
||||
**Equal duration was a shape match, and I carried Q10's *music* census across to
|
||||
voice on the strength of it.** The content does not support it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `S00A` chunk 2 | **digital silence** — 4 497 300 samples, peak −inf |
|
||||
| `ADV` chunk 2 | **0.60 × chunk 1** (best-fit scalar), residual **26.8 dB** below the target |
|
||||
|
||||
About 95 % of `ADV`'s second chunk is a −4.4 dB copy of the first. Two chunks of
|
||||
equal length, one silence and the other a scaled near-duplicate, are not two
|
||||
stems of one performance. ⚠️ **The claim had already travelled** — it is quoted in
|
||||
the Decoder's `voice-region-leading-chunk.md` — which is the failure PROTOCOL
|
||||
names: a wrong belief moving faster than its correction, through two documents
|
||||
that share a source.
|
||||
|
||||
### What it cost, and what changed
|
||||
|
||||
Summing chunk 1 with silence at `1/n` put `S00A`'s dialogue **6.02 dB down for
|
||||
nothing**: the exported file peaked at −16.2 dBFS against a source chunk peaking
|
||||
at −4.2. `export_voice` now drops a **digitally silent** chunk before the sum.
|
||||
That is arithmetic, not a content judgement — a silent input contributes nothing
|
||||
to a mix and counting it in the normalisation is simply my error.
|
||||
|
||||
**What `ADV`'s near-duplicate chunk 2 is remains open and it is still summed.**
|
||||
Whether the game plays both is a decoding question; 26.8 dB of residual is not
|
||||
nothing, and dropping a chunk because it correlates with another would be
|
||||
answering it.
|
||||
|
||||
### The leading chunk, decoded — structure, and not one word about content
|
||||
|
||||
The Decoder's ask was *"cutscene dialogue or mission dialogue"*. `ADV` region
|
||||
+ 1392, 394 packets: **84.553 s, stereo, 48 kHz, peak −2.48 dBFS, RMS −24.80**,
|
||||
with **6 silent gaps over 0.4 s below −50 dB totalling 45.3 s** — 54 % silence,
|
||||
the same duty cycle as the two full-length chunks (54 %, 55 %). So it is
|
||||
**speech-structured audio**: not a header, not padding, not noise.
|
||||
|
||||
🔴 **Which is as far as a measurement goes.** *Cutscene or mission* is an
|
||||
identification and this agent has no ears and no oracle. Envelope
|
||||
cross-correlation against the full-length chunks peaks at 0.768 **at the last lag
|
||||
in the search range**, which is where a statistic lands when it has found
|
||||
nothing, and it is not evidence. The Decoder's 🟡 stands, and its own leading
|
||||
hypothesis — an in-mission `VOICE_D_*` line — is untouched by any of this. The
|
||||
byte-span test it already built settles it the moment those regions are
|
||||
enumerated; nobody has to listen.
|
||||
|
||||
### Taken from the same message: `bank_header_len`, not `riffs.len()`
|
||||
|
||||
The Decoder's census warns that eight bank-header regions also yield three
|
||||
chunks, so the chunk count cannot say which structure you are in. **This exporter
|
||||
never used the count** — it selects on decoded duration, which is why it already
|
||||
handles both cases: `RT01A`'s 10 300 B leading chunk decodes to 9 ms and falls
|
||||
out on its own. But a duration tie is an *observation* and `bank_header_len` is
|
||||
*decoded*, so the rule switches the day `c1f3608` reaches `main`.
|
||||
`sylpheed-formats` is a path dependency and merging another agent's topic branch
|
||||
is not the port's to do.
|
||||
|
||||
Reference in New Issue
Block a user