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:
@@ -106,6 +106,49 @@ pub struct TextPreview {
|
||||
/// super-linearly, and disc config files are only a few KiB anyway.
|
||||
const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// One row in the pack browser's entry list. Owned/plain so it crosses the
|
||||
/// loader channel and lives in a resource without borrowing a `PakArchive`.
|
||||
#[derive(Clone)]
|
||||
pub struct PakRow {
|
||||
pub hash: u32,
|
||||
pub comp_size: u32,
|
||||
/// Decompressed payload length, or `comp_size` when the entry wasn't decoded.
|
||||
pub size: usize,
|
||||
/// Inner-format label (`IDXD`, a 4-char tag, `(not decoded)`, …).
|
||||
pub format: String,
|
||||
/// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries.
|
||||
pub identity: String,
|
||||
/// Parsed IDXD detail for the property table, when the entry is an object.
|
||||
pub detail: Option<PakDetail>,
|
||||
}
|
||||
|
||||
/// The parsed IDXD detail behind one entry, shown in the master-detail pane.
|
||||
#[derive(Clone)]
|
||||
pub struct PakDetail {
|
||||
pub schema_hash: u32,
|
||||
pub count: u32,
|
||||
/// Explicit (key, value) fields (defaulted fields omitted), owned.
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PakView {
|
||||
pub loaded: bool,
|
||||
pub name: String,
|
||||
pub rows: Vec<PakRow>,
|
||||
pub selected: Option<usize>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Total decompressed bytes we're willing to decode when labelling a pack's
|
||||
/// entries — bounds work on a huge `sound.pak`; entries past it show as
|
||||
/// `(not decoded)`.
|
||||
const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024;
|
||||
/// Skip decoding any single entry whose stored size exceeds this (cheap guard
|
||||
/// against a pathological entry, since `decompress_entry` has no output cap).
|
||||
const PAK_ENTRY_COMP_CAP: u32 = 16 * 1024 * 1024;
|
||||
|
||||
// ── SystemSet label ───────────────────────────────────────────────────────────
|
||||
|
||||
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
|
||||
@@ -127,6 +170,12 @@ enum IsoLoaderMsg {
|
||||
path: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// A `.pak` archive assembled + labelled off-thread (`error` set on failure).
|
||||
PakLoaded {
|
||||
name: String,
|
||||
rows: Vec<PakRow>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// User dismissed the file dialog — not an error.
|
||||
Cancelled,
|
||||
Error(String),
|
||||
@@ -162,6 +211,17 @@ struct PendingFileBytes {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One-frame staging buffer for a loaded pack; `apply_pak` (chained after)
|
||||
/// consumes it, freeing the previous preview and populating `PakView`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Resource, Default)]
|
||||
struct PendingPak {
|
||||
ready: bool,
|
||||
name: String,
|
||||
rows: Vec<PakRow>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct IsoLoaderPlugin;
|
||||
@@ -175,13 +235,15 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<TexturePreview>()
|
||||
.init_resource::<FileInfo>()
|
||||
// Registered unconditionally (even on wasm, where the load systems
|
||||
// below are cfg'd out) so the UI can always read it.
|
||||
.init_resource::<TextPreview>();
|
||||
// below are cfg'd out) so the UI can always read them.
|
||||
.init_resource::<TextPreview>()
|
||||
.init_resource::<PakView>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
app.init_resource::<IsoChannels>()
|
||||
.init_resource::<PendingFileBytes>()
|
||||
.init_resource::<PendingPak>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -190,6 +252,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
handle_file_selected,
|
||||
poll_loader_channel,
|
||||
apply_loaded_texture,
|
||||
apply_pak,
|
||||
)
|
||||
.chain()
|
||||
.in_set(IsoLoaderSystemSet),
|
||||
@@ -313,6 +376,75 @@ fn handle_file_selected(
|
||||
let file_path = file_path.clone();
|
||||
let sender = channels.sender.clone();
|
||||
|
||||
// `.pak` archives need their index plus sibling `.pNN` segments, and the
|
||||
// decode is heavy — assemble + label off-thread, then hand the UI plain
|
||||
// rows. Everything else falls through to the single-file read below.
|
||||
if file_path.to_ascii_lowercase().ends_with(".pak") {
|
||||
let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string();
|
||||
let base = file_path[..file_path.len() - 4].to_string(); // strip ".pak"
|
||||
match &iso_state.source_kind {
|
||||
SourceKind::Iso(iso_path) => {
|
||||
let iso_path = iso_path.clone();
|
||||
let pak_path = file_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = futures::executor::block_on(async {
|
||||
let mut reader = sylpheed_formats::xiso::open_iso(&iso_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let index = reader
|
||||
.read_file(&pak_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut data = Vec::new();
|
||||
for i in 0..100u32 {
|
||||
match reader.read_file(&format!("{base}.p{i:02}")).await {
|
||||
Ok(mut b) => data.append(&mut b),
|
||||
Err(_) => break, // first gap = end of segments
|
||||
}
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Err("no .pNN data segments found".to_string());
|
||||
}
|
||||
build_pak_rows(&index, data)
|
||||
});
|
||||
let (rows, error) = match result {
|
||||
Ok(rows) => (rows, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
|
||||
});
|
||||
}
|
||||
SourceKind::Directory(root) => {
|
||||
let root = root.clone();
|
||||
let pak_path = file_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root);
|
||||
let result = (|| {
|
||||
let index = assets.read(&pak_path).map_err(|e| e.to_string())?;
|
||||
let mut data = Vec::new();
|
||||
for i in 0..100u32 {
|
||||
match assets.read(&format!("{base}.p{i:02}")) {
|
||||
Ok(mut b) => data.append(&mut b),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Err("no .pNN data segments found".to_string());
|
||||
}
|
||||
build_pak_rows(&index, data)
|
||||
})();
|
||||
let (rows, error) = match result {
|
||||
Ok(rows) => (rows, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error });
|
||||
});
|
||||
}
|
||||
SourceKind::None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match &iso_state.source_kind {
|
||||
SourceKind::Iso(iso_path) => {
|
||||
let iso_path = iso_path.clone();
|
||||
@@ -360,6 +492,79 @@ fn handle_file_selected(
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a `PakArchive` from its index + concatenated segment data, then
|
||||
/// build one `PakRow` per entry (decompressing + IDXD-parsing under the decode
|
||||
/// budget). Runs on the loader thread; returns owned rows for the UI.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
use sylpheed_formats::pak::inner_format_label;
|
||||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||
|
||||
let arc = PakArchive::from_parts(index, data).map_err(|e| e.to_string())?;
|
||||
let mut rows = Vec::with_capacity(arc.entries().len());
|
||||
let mut decoded_budget: usize = 0;
|
||||
|
||||
for e in arc.entries() {
|
||||
// Skip decoding oversized or over-budget entries — bounds a huge pack.
|
||||
if e.comp_size > PAK_ENTRY_COMP_CAP || decoded_budget > PAK_DECODE_BUDGET {
|
||||
rows.push(PakRow {
|
||||
hash: e.name_hash,
|
||||
comp_size: e.comp_size,
|
||||
size: e.comp_size as usize,
|
||||
format: "(not decoded)".into(),
|
||||
identity: String::new(),
|
||||
detail: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match arc.read(e) {
|
||||
Ok(payload) => {
|
||||
decoded_budget += payload.len();
|
||||
let format = inner_format_label(&payload);
|
||||
let (identity, detail) = if IdxdObject::is_idxd(&payload) {
|
||||
match IdxdObject::parse(&payload) {
|
||||
Ok(obj) => {
|
||||
let fields = obj
|
||||
.resolved_fields()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
(
|
||||
obj.identity(),
|
||||
Some(PakDetail {
|
||||
schema_hash: obj.schema_hash,
|
||||
count: obj.count,
|
||||
fields,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => (String::new(), None),
|
||||
}
|
||||
} else {
|
||||
(String::new(), None)
|
||||
};
|
||||
rows.push(PakRow {
|
||||
hash: e.name_hash,
|
||||
comp_size: e.comp_size,
|
||||
size: payload.len(),
|
||||
format,
|
||||
identity,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
Err(err) => rows.push(PakRow {
|
||||
hash: e.name_hash,
|
||||
comp_size: e.comp_size,
|
||||
size: e.comp_size as usize,
|
||||
format: "(read error)".into(),
|
||||
identity: err.to_string(),
|
||||
detail: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
|
||||
/// `PendingFileBytes` as messages arrive.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -368,6 +573,7 @@ fn poll_loader_channel(
|
||||
mut iso_state: ResMut<IsoState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
mut pending: ResMut<PendingFileBytes>,
|
||||
mut pending_pak: ResMut<PendingPak>,
|
||||
) {
|
||||
let receiver = channels.receiver.lock().unwrap();
|
||||
loop {
|
||||
@@ -392,6 +598,12 @@ fn poll_loader_channel(
|
||||
pending.bytes = bytes;
|
||||
pending.ready = true;
|
||||
}
|
||||
Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => {
|
||||
pending_pak.name = name;
|
||||
pending_pak.rows = rows;
|
||||
pending_pak.error = error;
|
||||
pending_pak.ready = true;
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
}
|
||||
@@ -412,6 +624,7 @@ fn apply_loaded_texture(
|
||||
mut pending: ResMut<PendingFileBytes>,
|
||||
mut preview: ResMut<TexturePreview>,
|
||||
mut text_preview: ResMut<TextPreview>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
@@ -426,15 +639,15 @@ fn apply_loaded_texture(
|
||||
|
||||
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
||||
|
||||
// Always free the previous texture first (GPU memory + egui slot).
|
||||
// Always free the previous texture first (GPU memory + egui slot), and clear
|
||||
// the other previews so exactly one viewer is active per selection.
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::default();
|
||||
// Clear stale text too, so switching files never shows a previous file's
|
||||
// content in the wrong panel.
|
||||
*text_preview = TextPreview::default();
|
||||
*pak_view = PakView::default();
|
||||
|
||||
// Populate FileInfo for the status / info panel.
|
||||
file_info.name = path
|
||||
@@ -498,3 +711,42 @@ fn apply_loaded_texture(
|
||||
preview.height = h;
|
||||
preview.format_info = fmt_str;
|
||||
}
|
||||
|
||||
/// Consumes a staged pack, freeing the previous texture/text previews and
|
||||
/// populating `PakView` for the master-detail browser.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_pak(
|
||||
mut pending: ResMut<PendingPak>,
|
||||
mut pak_view: ResMut<PakView>,
|
||||
mut preview: ResMut<TexturePreview>,
|
||||
mut text_preview: ResMut<TextPreview>,
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
) {
|
||||
if !pending.ready {
|
||||
return;
|
||||
}
|
||||
pending.ready = false;
|
||||
|
||||
// Free any previous texture + clear the other previews (mirrors the texture
|
||||
// path) so exactly one viewer is active.
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::default();
|
||||
*text_preview = TextPreview::default();
|
||||
|
||||
file_info.name = pending.name.clone();
|
||||
file_info.size_bytes = 0;
|
||||
file_info.detected_format = None;
|
||||
|
||||
*pak_view = PakView {
|
||||
loaded: true,
|
||||
name: std::mem::take(&mut pending.name),
|
||||
rows: std::mem::take(&mut pending.rows),
|
||||
selected: None,
|
||||
error: pending.error.take(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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