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"))]