game_data: DemoMessage loader — the dialogue backbone
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 <noreply@anthropic.com>
This commit is contained in:
13
crates/sylpheed-formats/examples/dialogue.rs
Normal file
13
crates/sylpheed-formats/examples/dialogue.rs
Normal file
@@ -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::<Vec<_>>().join(" ");
|
||||
println!(" {who:10} [{}] “{}”", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::<String>());
|
||||
}
|
||||
}
|
||||
@@ -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<Squadron> {
|
||||
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<String>,
|
||||
/// Portrait / emotion (`FaceRAYMOND_07`).
|
||||
pub face: Option<String>,
|
||||
/// Voice clip token (`VOICE_A_007`).
|
||||
pub voice_clip: Option<String>,
|
||||
/// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text.
|
||||
pub page_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<DemoMessage> {
|
||||
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<usize> = 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 };
|
||||
|
||||
Reference in New Issue
Block a user