viewer: Game Data browser (View ▸ Game Data)

Wire the decoded game_data + localization tables into the viewer as a
browsable window. View ▸ Game Data opens a floating panel with category
tabs — Weapons, Craft, Capital Ships, Characters, Missions, Arsenal,
Flights — decoded off-thread from GP_MAIN_GAME_E + GP_HANGAR_ARSENAL.

- Weapons/Craft/Ships: sortable stat grids (power/velocity/range;
  HP/cruise/accel/radar/turrets; HP/length/turrets/bridges/shield-gen),
  with a live name filter.
- Characters: name (localized) + faction (colour-coded) + portrait count.
- Missions: collapsible per stage — location, resolved objectives, fail
  conditions, and the enemy roster with HP.
- Arsenal: the player's nose/arm weapon options in four columns.
- Flights: distinct wingman line-ups.

Its own egui system (keeps draw_viewer_ui under Bevy's 16-param limit);
a RequestGameData event from the menu opens it and lazily decodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-23 20:44:40 +02:00
parent d41b065813
commit df724b6bcb
2 changed files with 450 additions and 5 deletions

View File

@@ -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<String>,
pub lose: Vec<String>,
pub enemies: Vec<String>,
}
/// Everything the Game Data browser shows, decoded off-thread from the paks.
#[derive(Default)]
pub struct GameSnapshot {
pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
pub characters: Vec<CharRow>,
pub missions: Vec<MissionRow>,
pub arsenal: sylpheed_formats::game_data::Arsenal,
/// Distinct flight line-ups: `(callsign, pilot)` rows.
pub flights: Vec<Vec<(String, String)>>,
}
/// 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<sylpheed_formats::slb::VoiceClip>,
},
/// The decoded game-data tables for the browser.
GameDataLoaded(Box<GameSnapshot>),
/// User dismissed the file dialog — not an error.
Cancelled,
Error(String),
@@ -810,10 +895,12 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<MovieVoice>()
.init_resource::<AudioPreview>()
.init_resource::<VoiceLibrary>()
.init_resource::<GameData>()
.add_event::<RequestSubtitles>()
.add_event::<RequestVoice>()
.add_event::<RequestAudio>()
.add_event::<RequestVoiceLibrary>();
.add_event::<RequestVoiceLibrary>()
.add_event::<RequestGameData>();
#[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<MovieVoice>,
mut audio_preview: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>,
mut game_data: ResMut<GameData>,
) {
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<RequestGameData>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<GameSnapshot> {
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<CharRow> = 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<f32> {
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<MissionRow> = 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::<u32>().is_err() {
continue;
}
let objectives: Vec<String> =
(1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
let lose: Vec<String> =
(1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
let enemies: Vec<String> = 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::<Vec<_>>().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"))]

View File

@@ -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<f32>) -> String {
match v {
Some(x) if x == x.trunc() => format!("{}", x as i64),
Some(x) => format!("{x}"),
None => "·".into(),
}
}
fn fint(v: Option<i64>) -> 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<GameData>,
mut requests: EventReader<RequestGameData>,
) {
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;
}