diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 5dbf201..1044564 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -395,33 +395,7 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result 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" 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) -} +use sylpheed_formats::pak::inner_format_label as inner_label; /// 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 @@ -468,7 +442,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> { } let (detail, name) = if is_idxd { 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), } } else { diff --git a/crates/sylpheed-formats/src/idxd.rs b/crates/sylpheed-formats/src/idxd.rs index ee8477a..896ddf7 100644 --- a/crates/sylpheed-formats/src/idxd.rs +++ b/crates/sylpheed-formats/src/idxd.rs @@ -171,6 +171,18 @@ impl IdxdObject { } 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 diff --git a/crates/sylpheed-formats/src/pak.rs b/crates/sylpheed-formats/src/pak.rs index bc02007..6548389 100644 --- a/crates/sylpheed-formats/src/pak.rs +++ b/crates/sylpheed-formats/src/pak.rs @@ -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]]) } +/// 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", +} + +/// 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, + pub selected: Option, + pub error: Option, +} + +/// 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, }, + /// A `.pak` archive assembled + labelled off-thread (`error` set on failure). + PakLoaded { + name: String, + rows: Vec, + error: Option, + }, /// User dismissed the file dialog — not an error. Cancelled, Error(String), @@ -162,6 +211,17 @@ struct PendingFileBytes { bytes: Vec, } +/// 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, + error: Option, +} + // ── Plugin ──────────────────────────────────────────────────────────────────── pub struct IsoLoaderPlugin; @@ -175,13 +235,15 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() // Registered unconditionally (even on wasm, where the load systems - // below are cfg'd out) so the UI can always read it. - .init_resource::(); + // below are cfg'd out) so the UI can always read them. + .init_resource::() + .init_resource::(); #[cfg(not(target_arch = "wasm32"))] { app.init_resource::() .init_resource::() + .init_resource::() .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) -> Result, 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, mut browser: ResMut, mut pending: ResMut, + mut pending_pak: ResMut, ) { 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, mut preview: ResMut, mut text_preview: ResMut, + mut pak_view: ResMut, mut file_info: ResMut, mut images: ResMut>, 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, + mut pak_view: ResMut, + mut preview: ResMut, + mut text_preview: ResMut, + mut file_info: ResMut, + mut images: ResMut>, + 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(), + }; +} diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 83689d8..3adfb3e 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -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, preview: Res, text_preview: Res, + mut pak_view: ResMut, file_info: Res, mut open_iso_events: EventWriter, mut open_dir_events: EventWriter, @@ -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; + } +}