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:
@@ -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"))]
|
||||
|
||||
Reference in New Issue
Block a user