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;
|
pub const CHARACTER: u32 = 0xbd86_d41c;
|
||||||
/// Stage resource manifest — per mission: location, phases, resource tables.
|
/// Stage resource manifest — per mission: location, phases, resource tables.
|
||||||
pub const STAGE: u32 = 0x3c9a_e32e;
|
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"`).
|
/// 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
|
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) ─────────────────────────────
|
// ── Generic record access (any of the ~105 schemas) ─────────────────────────────
|
||||||
|
|
||||||
/// A generically-decoded IDXD record: its table name, schema id, identity, and
|
/// 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")));
|
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]
|
#[test]
|
||||||
fn generic_records_read_any_schema() {
|
fn generic_records_read_any_schema() {
|
||||||
let Some(pak) = pak() else { return };
|
let Some(pak) = pak() else { return };
|
||||||
|
|||||||
Reference in New Issue
Block a user