From 6e704291be569010cde5113a593c8d2afd96436f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 23 Jul 2026 19:57:28 +0200 Subject: [PATCH] =?UTF-8?q?game=5Fdata:=20DemoMessage=20loader=20=E2=80=94?= =?UTF-8?q?=20the=20dialogue=20backbone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode the message tables (schema b412e6d8) into dialogue lines: each `Message_NNN` sub-record → a DemoMessage with the speaker, portrait (face), voice-clip token, and caption page text-keys. Fields are found by prefix (Character*/Face*/VOICE_*/id-prefixed pages), so the positional / first-record-defines-schema layout doesn't matter. 10,263 lines parse; ~815 name an explicit speaker (the rest are system / continuation lines — left unattributed rather than guessed). Paired with localization::TextIndex the attributed ones read straight out — e.g. KATANA [VOICE_RHIN_000] "Ellen, get back into formation!". The voice-clip token ties each line to its audio bank, closing the loop with the cutscene-voice work. +1 test; example `dialogue` prints speaker + clip + resolved text. Co-Authored-By: Claude Opus 4.8 --- crates/sylpheed-formats/examples/dialogue.rs | 13 ++++ crates/sylpheed-formats/src/game_data.rs | 75 ++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 crates/sylpheed-formats/examples/dialogue.rs diff --git a/crates/sylpheed-formats/examples/dialogue.rs b/crates/sylpheed-formats/examples/dialogue.rs new file mode 100644 index 0000000..888114a --- /dev/null +++ b/crates/sylpheed-formats/examples/dialogue.rs @@ -0,0 +1,13 @@ +use sylpheed_formats::{game_data, localization::TextIndex, PakArchive}; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let text=TextIndex::build(&pak); + let msgs=game_data::load_demo_messages(&pak); + println!("{} dialogue lines total\n", msgs.len()); + for m in msgs.iter().filter(|m|m.character.is_some()&&!m.page_keys.is_empty()).take(8){ + let who=m.character.as_deref().unwrap_or("?").trim_start_matches("Character"); + let line:String=m.page_keys.iter().filter_map(|k|text.get(k)).collect::>().join(" "); + println!(" {who:10} [{}] “{}”", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::()); + } +} diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs index 958b5c1..b6ce3ab 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -37,6 +37,8 @@ pub mod schema { pub const CHARACTER: u32 = 0xbd86_d41c; /// Stage resource manifest — per mission: location, phases, resource tables. pub const STAGE: u32 = 0x3c9a_e32e; + /// Message / demo dialogue — per line: speaker, portrait, voice clip, pages. + pub const MESSAGE: u32 = 0xb412_e6d8; } /// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`). @@ -514,6 +516,63 @@ pub fn load_squadrons(pak: &PakArchive) -> Vec { out } +// ── Demo message / dialogue line ──────────────────────────────────────────────── + +/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which +/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the +/// page keys against a [`crate::localization::TextIndex`] for the words, and the +/// voice clip against the movie-voice banks for the audio. +#[derive(Debug, Clone)] +pub struct DemoMessage { + /// Line id, e.g. `MSG_VOICE_A_007`. + pub id: String, + /// Speaker (`CharacterRAYMOND`) when the line names one; many system / + /// continuation lines are unattributed (`None`). + pub character: Option, + /// Portrait / emotion (`FaceRAYMOND_07`). + pub face: Option, + /// Voice clip token (`VOICE_A_007`). + pub voice_clip: Option, + /// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text. + pub page_keys: Vec, +} + +/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the +/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the +/// positional/defaulted layout doesn't matter. +pub fn load_demo_messages(pak: &PakArchive) -> Vec { + let is_marker = |s: &str| { + s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + }; + let mut out = Vec::new(); + for e in pak.entries() { + let Ok(b) = pak.read(e) else { continue }; + let Ok(o) = IdxdObject::parse(&b) else { continue }; + if o.schema_hash != schema::MESSAGE { + continue; + } + let t = o.tokens(); + let marks: Vec = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect(); + for (k, &m) in marks.iter().enumerate() { + let end = marks.get(k + 1).copied().unwrap_or(t.len()); + let slice = &t[m + 1..end]; + let Some(id) = slice.first().cloned() else { continue }; + out.push(DemoMessage { + character: slice.iter().find(|s| s.starts_with("Character")).cloned(), + face: slice.iter().find(|s| s.starts_with("Face")).cloned(), + voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(), + page_keys: slice + .iter() + .filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_')) + .cloned() + .collect(), + id, + }); + } + } + out +} + // ── Generic record access (any of the ~105 schemas) ───────────────────────────── /// A generically-decoded IDXD record: its table name, schema id, identity, and @@ -668,6 +727,22 @@ mod tests { assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber"))); } + #[test] + fn loads_demo_messages() { + let Some(pak) = pak() else { return }; + let msgs = load_demo_messages(&pak); + assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len()); + // Most lines are `MSG_*` ids with a speaker; page keys extend the id. + let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count(); + assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len()); + // Attributed dialogue (an explicit speaker) is a minority — most lines are + // unattributed system/continuation messages — but there are hundreds. + let attributed = msgs.iter().filter(|m| m.character.is_some()).count(); + assert!(attributed > 500, "attributed lines: {attributed}"); + let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap(); + assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id))); + } + #[test] fn generic_records_read_any_schema() { let Some(pak) = pak() else { return };