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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user