diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index a157a46..a0db337 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -503,6 +503,89 @@ pub struct VoiceLibrary { #[derive(Event, Default)] pub struct RequestVoiceLibrary; +// ── Game-data browser (decoded IDXD tables) ──────────────────────────────────── + +/// Which decoded table the Game Data browser is showing. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum GameCategory { + #[default] + Weapons, + Craft, + Vessels, + Characters, + Missions, + Arsenal, + Flights, +} + +impl GameCategory { + pub const ALL: [GameCategory; 7] = [ + GameCategory::Weapons, + GameCategory::Craft, + GameCategory::Vessels, + GameCategory::Characters, + GameCategory::Missions, + GameCategory::Arsenal, + GameCategory::Flights, + ]; + pub fn label(self) -> &'static str { + match self { + GameCategory::Weapons => "Weapons", + GameCategory::Craft => "Craft", + GameCategory::Vessels => "Capital Ships", + GameCategory::Characters => "Characters", + GameCategory::Missions => "Missions", + GameCategory::Arsenal => "Arsenal", + GameCategory::Flights => "Flights", + } + } +} + +/// A character row, name resolved via localization. +pub struct CharRow { + pub name: String, + pub faction: String, + pub faces: usize, +} + +/// A mission row: stage + its resolved briefing. +pub struct MissionRow { + pub id: String, + pub location: String, + pub phases: u32, + pub objectives: Vec, + pub lose: Vec, + pub enemies: Vec, +} + +/// Everything the Game Data browser shows, decoded off-thread from the paks. +#[derive(Default)] +pub struct GameSnapshot { + pub weapons: Vec, + pub craft: Vec, + pub vessels: Vec, + pub characters: Vec, + pub missions: Vec, + pub arsenal: sylpheed_formats::game_data::Arsenal, + /// Distinct flight line-ups: `(callsign, pilot)` rows. + pub flights: Vec>, +} + +/// The Game Data browser state (decoded combat / mission / cast tables). +#[derive(Resource, Default)] +pub struct GameData { + pub open: bool, + pub loading: bool, + pub loaded: bool, + pub category: GameCategory, + pub filter: String, + pub snapshot: GameSnapshot, +} + +/// Ask the loader to decode the game-data tables. +#[derive(Event, Default)] +pub struct RequestGameData; + /// The currently-open IPFB data pack, shown as a master-detail browser. #[derive(Resource, Default)] pub struct PakView { @@ -593,6 +676,8 @@ enum IsoLoaderMsg { VoiceLibraryLoaded { clips: Vec, }, + /// The decoded game-data tables for the browser. + GameDataLoaded(Box), /// User dismissed the file dialog — not an error. Cancelled, Error(String), @@ -810,10 +895,12 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_event::() .add_event::() .add_event::() - .add_event::(); + .add_event::() + .add_event::(); #[cfg(not(target_arch = "wasm32"))] { @@ -842,6 +929,7 @@ impl Plugin for IsoLoaderPlugin { handle_audio_request, advance_audio_playback, handle_voice_library_request, + handle_game_data_request, ) .chain() .in_set(IsoLoaderSystemSet), @@ -1681,6 +1769,7 @@ fn poll_loader_channel( mut mvoice: ResMut, mut audio_preview: ResMut, mut voice_lib: ResMut, + mut game_data: ResMut, ) { let receiver = channels.receiver.lock().unwrap(); loop { @@ -1793,6 +1882,18 @@ fn poll_loader_channel( voice_lib.loaded = true; } } + Ok(IsoLoaderMsg::GameDataLoaded(snap)) => { + game_data.loading = false; + // An empty snapshot means the decode failed (no game source, or a + // bare pak) — leave `loaded=false` so re-opening retries. + let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty(); + if empty { + game_data.loaded = false; + } else { + game_data.snapshot = *snap; + game_data.loaded = true; + } + } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; browser.loading = false; @@ -3462,6 +3563,109 @@ fn handle_audio_request( }); } +/// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables +/// off-thread and posts the snapshot back for the browser. +#[cfg(not(target_arch = "wasm32"))] +fn handle_game_data_request( + mut events: EventReader, + iso_state: Res, + channels: Res, +) { + if events.read().next().is_none() { + return; + } + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + std::thread::spawn(move || { + let snap = build_game_snapshot(&source).unwrap_or_default(); + let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap))); + }); +} + +/// Decode the game-data tables into a browser snapshot (English pak + hangar pak). +#[cfg(not(target_arch = "wasm32"))] +fn build_game_snapshot(source: &SourceKind) -> Option { + use sylpheed_formats::{game_data as gd, localization::TextIndex}; + let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?; + let text = TextIndex::build(&main); + + let mut weapons = gd::load_weapons(&main); + weapons.sort_by(|a, b| a.id.cmp(&b.id)); + let mut craft = gd::load_units(&main); + craft.sort_by(|a, b| a.id.cmp(&b.id)); + let mut vessels = gd::load_vessels(&main); + vessels.sort_by(|a, b| a.id.cmp(&b.id)); + + let mut characters: Vec = gd::load_characters(&main) + .into_iter() + .filter_map(|c| { + let id = c.id?; + let short = id.trim_start_matches("Character").to_string(); + let name = text.character_name(&short).unwrap_or(&short).to_string(); + Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() }) + }) + .collect(); + characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone()))); + + // Combat rosters, keyed by stage where the table self-identifies. + let rosters = gd::load_unit_rosters(&main); + let stat_hp = |id: &str| -> Option { + craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp) + .or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp)) + }; + let mut missions: Vec = Vec::new(); + for s in gd::load_stages(&main) { + // main + extra story stages only (skip the Test stage) + if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::().is_err() { + continue; + } + let objectives: Vec = + (1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect(); + let lose: Vec = + (1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect(); + let enemies: Vec = rosters + .iter() + .find(|r| r.stage.as_deref() == Some(s.id.as_str())) + .map(|r| { + r.units.iter().filter(|u| u.contains("ADAN")).map(|u| { + let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " "); + match stat_hp(u) { + Some(h) => format!("{name} ({h:.0} HP)"), + None => name, + } + }).collect() + }) + .unwrap_or_default(); + missions.push(MissionRow { + id: s.id.clone(), + location: s.location.unwrap_or_default().replace('_', " "), + phases: s.phases, + objectives, + lose, + enemies, + }); + } + missions.sort_by(|a, b| a.id.cmp(&b.id)); + + // Arsenal + flights from the hangar pak (optional). + let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak") + .map(|h| { + let arsenal = gd::load_arsenal(&h); + let mut seen = std::collections::BTreeSet::new(); + let mut flights = Vec::new(); + for r in gd::load_pilot_rosters(&h) { + let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::>().join(","); + if seen.insert(key) { + flights.push(r.pilots); + } + } + (arsenal, flights) + }) + .unwrap_or_default(); + + Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights }) +} + /// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and /// enumerates the voice clips off-thread. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 4364602..2cf9a77 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -10,10 +10,10 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use crate::iso_loader::{ - AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles, - MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles, - RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary, - IsoLoaderSystemSet, + AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, + MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, + RequestOpenIso, RequestSubtitles, RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, + VideoPreview, VoiceLibrary, IsoLoaderSystemSet, }; use crate::ViewerState; use sylpheed_formats::SubLang; @@ -25,6 +25,7 @@ impl Plugin for ViewerUiPlugin { app.insert_resource(FileBrowserState::default()); // Run after the iso_loader chain so we see the frame's final state. app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet)); + app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet)); } } @@ -136,6 +137,7 @@ struct UiEvents<'w> { subtitles: EventWriter<'w, RequestSubtitles>, audio: EventWriter<'w, RequestAudio>, voice_lib: EventWriter<'w, RequestVoiceLibrary>, + game_data: EventWriter<'w, RequestGameData>, } fn draw_viewer_ui( @@ -193,6 +195,12 @@ fn draw_viewer_ui( } ui.close_menu(); } + if ui.button("🗃 Game Data…").clicked() { + // The Game Data window (its own system) reads this event to + // open + lazily decode the tables. + events.game_data.send_default(); + ui.close_menu(); + } }); ui.menu_button("Help", |ui| { @@ -1280,3 +1288,236 @@ fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) { }); }); } + +// ── Game Data browser ────────────────────────────────────────────────────────── + +fn fnum(v: Option) -> String { + match v { + Some(x) if x == x.trunc() => format!("{}", x as i64), + Some(x) => format!("{x}"), + None => "·".into(), + } +} +fn fint(v: Option) -> String { + v.map(|x| x.to_string()).unwrap_or_else(|| "·".into()) +} + +/// The Game Data browser — a floating window over the decoded IDXD tables +/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so +/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData` +/// event (from the View menu) opens it and lazily kicks off decoding. +#[cfg(not(target_arch = "wasm32"))] +fn draw_game_data_ui( + mut contexts: EguiContexts, + mut game_data: ResMut, + mut requests: EventReader, +) { + if requests.read().next().is_some() { + game_data.open = true; + if !game_data.loaded { + game_data.loading = true; + } + } + if !game_data.open { + return; + } + let ctx = contexts.ctx_mut().clone(); + let mut open = true; + egui::Window::new("🗃 Game Data") + .default_width(700.0) + .default_height(560.0) + .open(&mut open) + .show(&ctx, |ui| { + if game_data.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Decoding game tables…"); + }); + ctx.request_repaint(); + return; + } + if !game_data.loaded { + ui.label("Open a game source first (File ▸ Open…)."); + return; + } + let GameData { category, filter, snapshot, .. } = &mut *game_data; + + ui.horizontal_wrapped(|ui| { + for cat in GameCategory::ALL { + ui.selectable_value(category, cat, cat.label()); + } + }); + let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights); + if filterable { + ui.horizontal(|ui| { + ui.label("🔍"); + ui.text_edit_singleline(filter); + if !filter.is_empty() && ui.small_button("✖").clicked() { + filter.clear(); + } + }); + } + ui.separator(); + let f = filter.to_lowercase(); + let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f); + + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category { + GameCategory::Weapons => { + egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| { + for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] { + ui.strong(h); + } + ui.end_row(); + for w in &snapshot.weapons { + let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_"); + if !hit(name) { + continue; + } + ui.label(name); + ui.label(w.target_type.as_deref().unwrap_or("·")); + ui.label(fnum(w.power)); + ui.label(fnum(w.velocity)); + ui.label(fnum(w.max_range)); + ui.label(fint(w.loading_count)); + ui.end_row(); + } + }); + } + GameCategory::Craft => { + egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| { + for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] { + ui.strong(h); + } + ui.end_row(); + for u in &snapshot.craft { + let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_"); + if !hit(name) { + continue; + } + ui.label(name); + ui.label(fnum(u.hp)); + ui.label(fnum(u.cruising_velocity)); + ui.label(fnum(u.acceleration)); + ui.label(fnum(u.radar_range)); + ui.label(fint(u.turret_count)); + ui.end_row(); + } + }); + } + GameCategory::Vessels => { + egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| { + for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] { + ui.strong(h); + } + ui.end_row(); + for v in &snapshot.vessels { + let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_"); + if !hit(name) { + continue; + } + ui.label(name); + ui.label(fnum(v.hp)); + ui.label(fnum(v.size_z)); + ui.label(fint(v.turret_count)); + ui.label(fint(v.bridge_count)); + ui.label(fint(v.shield_generator_count)); + ui.end_row(); + } + }); + } + GameCategory::Characters => { + egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| { + for h in ["Name", "Faction", "Portraits"] { + ui.strong(h); + } + ui.end_row(); + for c in &snapshot.characters { + if !hit(&c.name) && !hit(&c.faction) { + continue; + } + ui.label(&c.name); + let col = if c.faction == "TCAF" { + egui::Color32::from_rgb(90, 160, 232) + } else if c.faction == "ADAN" { + egui::Color32::from_rgb(224, 86, 122) + } else { + egui::Color32::GRAY + }; + ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction }); + ui.label(c.faces.to_string()); + ui.end_row(); + } + }); + } + GameCategory::Missions => { + for m in &snapshot.missions { + if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) { + continue; + } + let head = format!("{} · {} · {} phases", m.id, m.location, m.phases); + egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| { + if !m.objectives.is_empty() { + ui.strong("Objectives"); + for o in &m.objectives { + ui.label(format!("▸ {o}")); + } + } + if !m.lose.is_empty() { + ui.add_space(4.0); + ui.strong("Fail conditions"); + for l in &m.lose { + ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l); + } + } + if !m.enemies.is_empty() { + ui.add_space(4.0); + ui.strong("Enemy roster"); + for e in &m.enemies { + ui.label(format!("• {e}")); + } + } + }); + } + } + GameCategory::Arsenal => { + ui.columns(4, |cols| { + for (i, (title, list)) in [ + ("Nose", &snapshot.arsenal.nose), + ("Arm 1", &snapshot.arsenal.arm1), + ("Arm 2", &snapshot.arsenal.arm2), + ("Arm 3", &snapshot.arsenal.arm3), + ] + .into_iter() + .enumerate() + { + cols[i].strong(format!("{title} ({})", list.len())); + for w in list { + cols[i].label(w.replace('_', " ")); + } + } + }); + } + GameCategory::Flights => { + ui.label( + egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(), + ); + ui.add_space(4.0); + for (n, lineup) in snapshot.flights.iter().enumerate() { + egui::CollapsingHeader::new(format!("Line-up {}", n + 1)) + .id_salt(n) + .default_open(n == 0) + .show(ui, |ui| { + egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| { + for (cs, pilot) in lineup { + ui.label(cs); + ui.strong(pilot); + ui.end_row(); + } + }); + }); + } + } + }); + }); + game_data.open &= open; +}