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:
MechaCat02
2026-07-10 23:17:10 +02:00
parent 2658dd20a6
commit fbd4550d62
5 changed files with 391 additions and 36 deletions

View File

@@ -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;
}
}