diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index b3f755b8..e36d5973 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -95,6 +95,18 @@ struct ManifestAudio { peak_dbfs: Option, #[serde(skip_serializing_if = "Option::is_none")] duration_s: Option, + /// 🔴 One line saying what this asset is KNOWN to be missing, for the + /// runtime to announce. Absent means nothing is known to be missing -- + /// never that the asset was checked and is complete. + /// + /// It exists because the export could already say this and the RUNTIME + /// could not. `why` carries the full account, but it is a paragraph aimed + /// at a reader of the manifest; a player hears clean dialogue and has no + /// way to learn that a stream is absent from it. This port already + /// announces the two measured screens NEW GAME jumps over, on the principle + /// that a gap is announced before it is opened. Audio had no equivalent. + #[serde(skip_serializing_if = "Option::is_none")] + incomplete: Option, /// The game's own cue identifier where one is a NAME MATCH. Absent means /// nobody has claimed one -- never that the binding is unknown. #[serde(skip_serializing_if = "Option::is_none")] @@ -505,6 +517,15 @@ impl From for ManifestAudio { why: a.why, peak_dbfs: a.peak_dbfs, duration_s: a.duration_s, + incomplete: (a.kept_waves < a.sub_waves).then(|| { + format!( + "{} of {} streams. The running game decodes all {} concurrently. \ + Nothing in the audio reveals the gap -- what plays is clean dialogue. \ + WHICH streams are dropped and why differs per asset; the manifest \ + entry's `why` says, and it is not the same story twice.", + a.kept_waves, a.sub_waves, a.sub_waves + ) + }), name_match: a.name_match, loop_mode: a.loop_mode, } diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 6587cd5d..67530bdb 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -128 sections. Search this before re-deriving anything. +129 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -139,6 +139,7 @@ dies, which is what this file is for. * [The voice export now carries every qualifying stream — and a unity sum was refused by our own check](#the-voice-export-now-carries-every-qualifying-stream--and-a-unity-sum-was-refused-by-our-own-check) * [Their stream assignment does not fit my region — weights NOT applied](#their-stream-assignment-does-not-fit-my-region--weights-not-applied) * [The resolver starts late, and my "duplicate tail" was a real stream all along](#the-resolver-starts-late-and-my-duplicate-tail-was-a-real-stream-all-along) +* [The export knew the voice was incomplete; the runtime did not say so](#the-export-knew-the-voice-was-incomplete-the-runtime-did-not-say-so) ## P0 — the exporter, 2026-08-28 @@ -7498,3 +7499,46 @@ not have: ✅ The assignment itself still stands — their ratio test was chosen to be immune to the clipping — but ⚠️ chunk 0's absolute level was measured over 62 % of its stream, so its 0.05 dB agreement is luckier than it reads. + +## The export knew the voice was incomplete; the runtime did not say so + +The manifest has carried a full account of the voice export's gap for weeks, and +the runtime printed `+ voice ADV` and nothing else. That asymmetry is the +dangerous one for audio specifically: a reader of `manifest.json` gets a +paragraph, and a person **listening** gets clean dialogue with no way to learn +that a stream is absent from it. + +This port already governs the same situation elsewhere — NEW GAME announces the +two measured screens it jumps over rather than skipping them silently. Audio had +no equivalent, so: + +* `ManifestAudio` gains **`incomplete`**, one line naming what an asset is *known* + to be missing. Absent means nothing is known to be missing — **never** that the + asset was checked and found complete. +* `MenuAudio` carries it alongside the stream, and `_play_video` prints it at the + moment the voice starts. + +``` + + voice ADV + 🔴 KNOWN INCOMPLETE: 2 of 3 streams. The running game decodes all 3 … +``` + +Verified on both paths — the boot's `ADV` and P7's `S00A`. + +### 🔴 And the first version of the message was false for one of the two assets + +It read *"one is a start-truncated stream this export drops"*. That is `ADV`'s +story: its dropped chunk is the start-clipped remainder of ctx0. **`S00A`'s +dropped chunks are digitally silent** — a different reason entirely — and the +message would have told anyone running the new-game intro something untrue about +their own asset. + +Caught by reading the output for both, which took one command and which I nearly +skipped because the `ADV` line was obviously correct. The wording now states the +counts and points at the entry's `why`, because **which streams are dropped and +why is not the same story twice** and a single sentence cannot carry both. + +⚠️ Worth naming as its own shape: a message generated *once* from a template but +*true* only for the case it was written against. It is the failure mode of every +generic warning, and it is harder to see than a wrong number because the sentence +is well-formed and confident in both places. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 7c74360f..234ac8c2 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -543,6 +543,17 @@ func _play_video(name: String, skippable: bool) -> void: # would be authoring a sync constant nobody measured. if audio.play_voice(name): print(" + voice %s" % name) + # 🔴 SAY WHAT IS MISSING, at the moment it is played. + # + # The manifest has known the voice export is incomplete for weeks and the + # runtime did not repeat it. That asymmetry is the dangerous one for + # audio: a reader of `manifest.json` gets a paragraph, and a person + # LISTENING gets clean dialogue with no way to learn a stream is absent. + # The same principle already governs NEW GAME, which announces the two + # measured screens it jumps over rather than skipping them silently. + var gap := audio.incomplete_for(name) + if gap != "": + print(" 🔴 KNOWN INCOMPLETE: %s" % gap) else: # Said out loud: silence is the audio failure that looks like success, # and "this cutscene is unvoiced" is a real answer for most of the disc. diff --git a/port/scripts/menu_audio.gd b/port/scripts/menu_audio.gd index 7bca3419..00b72bc3 100644 --- a/port/scripts/menu_audio.gd +++ b/port/scripts/menu_audio.gd @@ -24,6 +24,14 @@ extends Node var cues: Dictionary = {} ## Role -> {stream, loop}, from the entries of kind `bgm`. var beds: Dictionary = {} +## Movie name -> what that voice export is KNOWN to be missing, from the +## manifest's `incomplete`. Empty for an asset with no known gap. +var _voice_gaps: Dictionary = {} + +## What `movie`'s voice export is known to be missing, or "" if nothing is. +func incomplete_for(movie: String) -> String: + return String(_voice_gaps.get(movie, "")) + ## Movie name -> stream, from the entries of kind `voice`. ## ## A cutscene's dialogue is NOT in its `.ogv`. On this disc a movie carries music @@ -86,6 +94,11 @@ func configure(tree: ExportTree) -> bool: # authored about which recording belongs to which picture. stream.loop = false voices[String(entry["name"])] = stream + # Carried alongside the stream so the runtime can announce a known gap + # at the moment it plays one. Absent means nothing is KNOWN to be + # missing -- never that the asset was checked and is complete. + if entry.has("incomplete"): + _voice_gaps[String(entry["name"])] = String(entry["incomplete"]) _: push_warning("manifest audio entry %s has kind %s, which this build does not play" % [entry.get("name", "?"), entry.get("kind", "?")])