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

@@ -395,33 +395,7 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
// ── pak list ──────────────────────────────────────────────────────────────── // ── pak list ────────────────────────────────────────────────────────────────
/// Best-effort human label for a decompressed entry's inner format. use sylpheed_formats::pak::inner_format_label as inner_label;
fn inner_label(payload: &[u8]) -> String {
if payload.len() < 4 {
return "empty".into();
}
let m = &payload[0..4];
if m == b"IDXD" {
"IDXD".into()
} else if payload.starts_with(b"<?xml") {
"xml".into()
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
String::from_utf8_lossy(m).into_owned()
} else {
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
}
}
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
fn idxd_identity(obj: &IdxdObject) -> String {
for key in ["ID", "Name", "Model"] {
if let Some(v) = obj.get_raw(key) {
return format!("{key}={v}");
}
}
format!("schema {:08x}", obj.schema_hash)
}
/// Try to recover an IDXD entry's original TOC path from its identity tokens. /// Try to recover an IDXD entry's original TOC path from its identity tokens.
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as /// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
@@ -468,7 +442,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
} }
let (detail, name) = if is_idxd { let (detail, name) = if is_idxd {
match IdxdObject::parse(&payload) { match IdxdObject::parse(&payload) {
Ok(o) => (idxd_identity(&o), idxd_toc_name(&o, e.name_hash)), Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)),
Err(_) => (String::new(), None), Err(_) => (String::new(), None),
} }
} else { } else {

View File

@@ -171,6 +171,18 @@ impl IdxdObject {
} }
out out
} }
/// A short identity string for the object: the first of `ID` / `Name` /
/// `Model` present, else the schema hash. Shared by the CLI `pak list` and
/// the GUI pack browser.
pub fn identity(&self) -> String {
for key in ["ID", "Name", "Model"] {
if let Some(v) = self.get_raw(key) {
return format!("{key}={v}");
}
}
format!("schema {:08x}", self.schema_hash)
}
} }
/// Extract the trailing string pool. Finds the smallest offset whose suffix is /// Extract the trailing string pool. Finds the smallest offset whose suffix is

View File

@@ -262,6 +262,26 @@ fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
} }
/// Best-effort human label for a decompressed entry's inner format, from its
/// first bytes: `IDXD`, `xml`, a printable 4-char tag (RATC, IXUD, LSTA, …), or
/// a hex fallback. Shared by the CLI `pak list` and the GUI pack browser.
pub fn inner_format_label(payload: &[u8]) -> String {
if payload.len() < 4 {
return "empty".into();
}
let m = &payload[0..4];
if m == b"IDXD" {
"IDXD".into()
} else if payload.starts_with(b"<?xml") {
"xml".into()
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
String::from_utf8_lossy(m).into_owned()
} else {
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -106,6 +106,49 @@ pub struct TextPreview {
/// super-linearly, and disc config files are only a few KiB anyway. /// super-linearly, and disc config files are only a few KiB anyway.
const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; 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 ─────────────────────────────────────────────────────────── // ── SystemSet label ───────────────────────────────────────────────────────────
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's /// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
@@ -127,6 +170,12 @@ enum IsoLoaderMsg {
path: String, path: String,
bytes: Vec<u8>, 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. /// User dismissed the file dialog — not an error.
Cancelled, Cancelled,
Error(String), Error(String),
@@ -162,6 +211,17 @@ struct PendingFileBytes {
bytes: Vec<u8>, 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 ──────────────────────────────────────────────────────────────────── // ── Plugin ────────────────────────────────────────────────────────────────────
pub struct IsoLoaderPlugin; pub struct IsoLoaderPlugin;
@@ -175,13 +235,15 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<TexturePreview>() .init_resource::<TexturePreview>()
.init_resource::<FileInfo>() .init_resource::<FileInfo>()
// Registered unconditionally (even on wasm, where the load systems // Registered unconditionally (even on wasm, where the load systems
// below are cfg'd out) so the UI can always read it. // below are cfg'd out) so the UI can always read them.
.init_resource::<TextPreview>(); .init_resource::<TextPreview>()
.init_resource::<PakView>();
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
app.init_resource::<IsoChannels>() app.init_resource::<IsoChannels>()
.init_resource::<PendingFileBytes>() .init_resource::<PendingFileBytes>()
.init_resource::<PendingPak>()
.add_systems( .add_systems(
Update, Update,
( (
@@ -190,6 +252,7 @@ impl Plugin for IsoLoaderPlugin {
handle_file_selected, handle_file_selected,
poll_loader_channel, poll_loader_channel,
apply_loaded_texture, apply_loaded_texture,
apply_pak,
) )
.chain() .chain()
.in_set(IsoLoaderSystemSet), .in_set(IsoLoaderSystemSet),
@@ -313,6 +376,75 @@ fn handle_file_selected(
let file_path = file_path.clone(); let file_path = file_path.clone();
let sender = channels.sender.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 { match &iso_state.source_kind {
SourceKind::Iso(iso_path) => { SourceKind::Iso(iso_path) => {
let iso_path = iso_path.clone(); 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 /// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
/// `PendingFileBytes` as messages arrive. /// `PendingFileBytes` as messages arrive.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -368,6 +573,7 @@ fn poll_loader_channel(
mut iso_state: ResMut<IsoState>, mut iso_state: ResMut<IsoState>,
mut browser: ResMut<FileBrowserState>, mut browser: ResMut<FileBrowserState>,
mut pending: ResMut<PendingFileBytes>, mut pending: ResMut<PendingFileBytes>,
mut pending_pak: ResMut<PendingPak>,
) { ) {
let receiver = channels.receiver.lock().unwrap(); let receiver = channels.receiver.lock().unwrap();
loop { loop {
@@ -392,6 +598,12 @@ fn poll_loader_channel(
pending.bytes = bytes; pending.bytes = bytes;
pending.ready = true; 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) => { Ok(IsoLoaderMsg::Cancelled) => {
iso_state.loading = false; iso_state.loading = false;
} }
@@ -412,6 +624,7 @@ fn apply_loaded_texture(
mut pending: ResMut<PendingFileBytes>, mut pending: ResMut<PendingFileBytes>,
mut preview: ResMut<TexturePreview>, mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>, mut text_preview: ResMut<TextPreview>,
mut pak_view: ResMut<PakView>,
mut file_info: ResMut<FileInfo>, mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>, mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts, mut contexts: bevy_egui::EguiContexts,
@@ -426,15 +639,15 @@ fn apply_loaded_texture(
let fmt = sylpheed_formats::vfs::identify_format(&bytes); 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 { if let Some(ref handle) = preview.handle {
contexts.remove_image(handle); contexts.remove_image(handle);
images.remove(handle.id()); images.remove(handle.id());
} }
*preview = TexturePreview::default(); *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(); *text_preview = TextPreview::default();
*pak_view = PakView::default();
// Populate FileInfo for the status / info panel. // Populate FileInfo for the status / info panel.
file_info.name = path file_info.name = path
@@ -498,3 +711,42 @@ fn apply_loaded_texture(
preview.height = h; preview.height = h;
preview.format_info = fmt_str; 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(),
};
}

View File

@@ -10,8 +10,8 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts}; use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{ use crate::iso_loader::{
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview, FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview,
IsoLoaderSystemSet, TexturePreview, IsoLoaderSystemSet,
}; };
use crate::{ViewerState, ViewMode}; use crate::{ViewerState, ViewMode};
@@ -118,6 +118,7 @@ fn draw_viewer_ui(
iso_state: Res<IsoState>, iso_state: Res<IsoState>,
preview: Res<TexturePreview>, preview: Res<TexturePreview>,
text_preview: Res<TextPreview>, text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
file_info: Res<FileInfo>, file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>, mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>, mut open_dir_events: EventWriter<RequestOpenDir>,
@@ -258,7 +259,9 @@ fn draw_viewer_ui(
// ── Central area ────────────────────────────────────────────────────── // ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() { if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| { 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 ── // ── Plain-text viewer ──
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.heading(&file_info.name); 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;
}
}