feat(viewer): data pack (.pak / IDXD) browser
Roadmap #3. The explorer can now open IPFB data packs — the game's ship/weapon/ mission definition tables. Selecting a `.pak` shows a master-detail browser: entry list (hash · inner-format · identity) on the left, the selected IDXD object's schema + explicit-field property table on the right. Load path: a `.pak` selection reads the index plus its sibling `.pNN` segments (ISO reader or extracted dir, probing p00.. until the first gap), assembles via PakArchive::from_parts, and builds owned PakRow/PakDetail entirely off-thread — so the UI holds only plain data (no borrow of a PakArchive, WASM-safe). Two decode caps (64 MiB cumulative, 16 MiB/entry) keep a huge sound.pak from hanging; over-budget entries show as "(not decoded)". Wiring mirrors the texture path: new IsoLoaderMsg::PakLoaded → PendingPak staging → apply_pak. A shared reset (free GPU handle + clear texture/text/pak previews) runs in both apply systems, so exactly one viewer is active per selection and the prior GPU texture is always freed when switching modes. Reuse over duplication: the CLI's inner_label / idxd_identity move into the formats crate as pak::inner_format_label and IdxdObject::identity (bodies verbatim → CLI output unchanged), now shared by CLI and GUI. PakView registered unconditionally (wasm-safe; population native-only). ViewMode unchanged — the central panel dispatches on the populated resource. Workspace builds; 22 formats tests + all crate tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,8 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview,
|
||||
IsoLoaderSystemSet,
|
||||
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview,
|
||||
TexturePreview, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::{ViewerState, ViewMode};
|
||||
|
||||
@@ -118,6 +118,7 @@ fn draw_viewer_ui(
|
||||
iso_state: Res<IsoState>,
|
||||
preview: Res<TexturePreview>,
|
||||
text_preview: Res<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
@@ -258,7 +259,9 @@ fn draw_viewer_ui(
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if let Some(text) = &text_preview.content {
|
||||
if pak_view.loaded {
|
||||
draw_pak_browser(ui, &mut pak_view);
|
||||
} else if let Some(text) = &text_preview.content {
|
||||
// ── Plain-text viewer ──
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&file_info.name);
|
||||
@@ -382,3 +385,97 @@ fn draw_viewer_ui(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
|
||||
|
||||
/// Master-detail view for an open `.pak`: entry list on the left, the selected
|
||||
/// entry's IDXD schema + property table on the right.
|
||||
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&pak.name);
|
||||
ui.separator();
|
||||
ui.label(format!("{} entries", pak.rows.len()));
|
||||
});
|
||||
if let Some(err) = &pak.error {
|
||||
ui.separator();
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Clone rows so the two column closures can read them while we record the
|
||||
// click into a local (avoids borrowing `pak` mutably inside the closures).
|
||||
let rows = pak.rows.clone();
|
||||
let selected = pak.selected;
|
||||
let mut new_selection = None;
|
||||
|
||||
ui.columns(2, |cols| {
|
||||
// Left — entry list.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_list")
|
||||
.show(&mut cols[0], |ui| {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let text = egui::RichText::new(format!(
|
||||
"{:08x} {:<7} {}",
|
||||
row.hash, row.format, row.identity
|
||||
))
|
||||
.monospace();
|
||||
if ui
|
||||
.add(egui::SelectableLabel::new(selected == Some(i), text))
|
||||
.clicked()
|
||||
{
|
||||
new_selection = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Right — detail of the selected entry.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("pak_detail")
|
||||
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
|
||||
None => {
|
||||
ui.label("Select an entry to inspect.");
|
||||
}
|
||||
Some(row) => match &row.detail {
|
||||
Some(d) => {
|
||||
let title = if row.identity.is_empty() {
|
||||
row.format.as_str()
|
||||
} else {
|
||||
row.identity.as_str()
|
||||
};
|
||||
ui.heading(title);
|
||||
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
ui.separator();
|
||||
if d.fields.is_empty() {
|
||||
ui.label("(no explicit-value fields)");
|
||||
} else {
|
||||
egui::Grid::new("pak_fields")
|
||||
.striped(true)
|
||||
.num_columns(2)
|
||||
.show(ui, |ui| {
|
||||
for (k, v) in &d.fields {
|
||||
ui.label(k);
|
||||
ui.monospace(v);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.heading(&row.format);
|
||||
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
|
||||
if !row.identity.is_empty() {
|
||||
ui.label(&row.identity);
|
||||
}
|
||||
ui.separator();
|
||||
ui.label("Not an IDXD object — no field table.");
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if new_selection.is_some() {
|
||||
pak.selected = new_selection;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user