TXCM XPR2 resources are the game's world skyboxes (BG_Acheron, BG_Hargenteen, …). The viewer showed only face 0 as a flat 2D image; now it decodes all 6 faces and shows a labelled grid. Formats (Bevy-free): X360Texture::cube_faces_from_xpr2() returns a Cubemap with 6 faces in D3D9 order (+X −X +Y −Y +Z −Z), or None for ordinary 2D textures. Face layout derived from xenia's GetGuestTextureLayout — 6 back-to-back independently- tiled surfaces, per-face stride = tiled-surface-size aligned to the 4 KiB subresource boundary (kTextureSubresourceAlignmentBytes). Verified against real BG_Acheron: data_size == 6 × 0x400000, and all 6 faces decode cleanly (own Python decode + a disc test asserting 6×4 MiB faces and face 0 == the validated from_xpr2 green-planet decode). Extracted a shared decode_surface() helper so the 2D and cube paths are byte-identical; from_xpr2 output unchanged (re-verified). Viewer: new SkyboxPreview resource (6 egui face textures, reusing the existing per-format x360_texture_to_bevy_image); populated in apply_loaded_texture's TXCM branch; freed/reset alongside the other previews (factored free_texture/ free_skybox helpers). Central panel gains a skybox branch rendering a 3-column labelled face grid. DEFERRED (per "do not guess, else defer"): the interactive 3D skybox. Face data + D3D9 order are validated, but wgpu cube-sampling handedness can't be confirmed without eyeballing the GUI — a wrong-oriented skybox is worse than the correct labelled grid. The grid is the reliable deliverable; the 3D look-around is a follow-up once orientation is visually confirmed. (Background agent did the investigation/validation but was blocked from writing files; implemented here in the main tree from its findings, independently re-verified.) 23 formats tests + disc cubemap test pass; viewer/CLI build; face-0 export re-verified as the green planet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
841 lines
30 KiB
Rust
841 lines
30 KiB
Rust
//! ISO / extracted-directory loading pipeline for the viewer.
|
||
//!
|
||
//! Bridges the async `XisoReader` API (and synchronous `GameAssets`) with
|
||
//! Bevy's ECS via background threads and `std::sync::mpsc` channels polled
|
||
//! each frame — no blocking on the main thread.
|
||
//!
|
||
//! ## Data flow (native)
|
||
//!
|
||
//! ```text
|
||
//! UI click → Event → handle_open_iso / handle_open_dir / handle_file_selected
|
||
//! └─ std::thread ─→ mpsc::Sender<IsoLoaderMsg>
|
||
//! ↓ (next frame)
|
||
//! poll_loader_channel → FileBrowserState / PendingFileBytes
|
||
//! ↓
|
||
//! apply_loaded_texture → TexturePreview (Image + egui TextureId)
|
||
//! ↓
|
||
//! draw_viewer_ui → renders preview panel
|
||
//! ```
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use std::sync::{mpsc, Mutex};
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use bevy::prelude::*;
|
||
use bevy_egui::egui;
|
||
|
||
use sylpheed_formats::vfs::FileFormat;
|
||
|
||
use crate::ui::FileBrowserState;
|
||
|
||
// ── Events ────────────────────────────────────────────────────────────────────
|
||
|
||
/// Fired by the "Open ISO disc image…" menu item.
|
||
#[derive(Event, Default)]
|
||
pub struct RequestOpenIso;
|
||
|
||
/// Fired by the "Open extracted folder…" menu item.
|
||
#[derive(Event, Default)]
|
||
pub struct RequestOpenDir;
|
||
|
||
/// Fired when the user clicks a file in the browser panel.
|
||
#[derive(Event)]
|
||
pub struct FileSelected(pub String);
|
||
|
||
// ── Resources (platform-agnostic) ────────────────────────────────────────────
|
||
|
||
/// Which kind of source is currently open.
|
||
#[derive(Default, Clone)]
|
||
pub enum SourceKind {
|
||
#[default]
|
||
None,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
Iso(PathBuf),
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
Directory(PathBuf),
|
||
}
|
||
|
||
/// State of the currently-open file source (ISO or extracted folder).
|
||
#[derive(Resource, Default)]
|
||
pub struct IsoState {
|
||
/// Short description shown in the status bar (filename or directory path).
|
||
pub source_label: Option<String>,
|
||
/// True while a background thread is loading.
|
||
pub loading: bool,
|
||
/// The most recent error message (cleared when a new operation starts).
|
||
pub error: Option<String>,
|
||
/// Which backend to use when reading individual files.
|
||
pub source_kind: SourceKind,
|
||
}
|
||
|
||
/// The texture currently shown in the central panel.
|
||
#[derive(Resource, Default)]
|
||
pub struct TexturePreview {
|
||
/// Bevy handle — kept alive so the GPU texture stays allocated.
|
||
pub handle: Option<Handle<Image>>,
|
||
/// egui texture ID registered via `EguiContexts::add_image`.
|
||
pub egui_id: Option<egui::TextureId>,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Human-readable format / dimension summary (or error message).
|
||
pub format_info: String,
|
||
}
|
||
|
||
/// Info shown for any selected file (texture or not).
|
||
#[derive(Resource, Default)]
|
||
pub struct FileInfo {
|
||
pub name: String,
|
||
pub size_bytes: usize,
|
||
pub detected_format: Option<FileFormat>,
|
||
}
|
||
|
||
/// Decoded contents of the currently-selected text file, shown in the central
|
||
/// panel. `content` is `None` whenever the selection isn't text.
|
||
#[derive(Resource, Default)]
|
||
pub struct TextPreview {
|
||
/// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`.
|
||
pub content: Option<String>,
|
||
/// Encoding label for the header, e.g. "UTF-16LE (BOM)".
|
||
pub encoding: String,
|
||
/// True when the file was larger than `MAX_TEXT_BYTES` and got clipped.
|
||
pub truncated: bool,
|
||
}
|
||
|
||
/// Cap on the decoded text we hand to egui — its text layout cost grows
|
||
/// 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)>,
|
||
}
|
||
|
||
/// One decoded cubemap face, registered as an egui image.
|
||
pub struct FaceTex {
|
||
pub label: &'static str,
|
||
pub handle: Handle<Image>,
|
||
pub egui_id: egui::TextureId,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
/// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid.
|
||
#[derive(Resource, Default)]
|
||
pub struct SkyboxPreview {
|
||
pub faces: Vec<FaceTex>,
|
||
/// Header summary (dimensions / format).
|
||
pub info: 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
|
||
/// final state before rendering.
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct IsoLoaderSystemSet;
|
||
|
||
// ── Native-only types ─────────────────────────────────────────────────────────
|
||
|
||
/// Messages sent from background threads back to the Bevy main thread.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
enum IsoLoaderMsg {
|
||
FilesListed {
|
||
source: SourceKind,
|
||
label: String,
|
||
files: Vec<String>,
|
||
},
|
||
FileLoaded {
|
||
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),
|
||
}
|
||
|
||
/// Channel resource. `Receiver<T>` is `!Sync`, so we wrap it in `Mutex`
|
||
/// to satisfy Bevy's `Resource: Send + Sync` bound.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[derive(Resource)]
|
||
struct IsoChannels {
|
||
sender: mpsc::Sender<IsoLoaderMsg>,
|
||
receiver: Mutex<mpsc::Receiver<IsoLoaderMsg>>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl Default for IsoChannels {
|
||
fn default() -> Self {
|
||
let (sender, receiver) = mpsc::channel();
|
||
Self {
|
||
sender,
|
||
receiver: Mutex::new(receiver),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One-frame staging buffer: `poll_loader_channel` deposits raw bytes here;
|
||
/// `apply_loaded_texture` (chained after) consumes them.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[derive(Resource, Default)]
|
||
struct PendingFileBytes {
|
||
ready: bool,
|
||
path: String,
|
||
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;
|
||
|
||
impl Plugin for IsoLoaderPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.add_event::<RequestOpenIso>()
|
||
.add_event::<RequestOpenDir>()
|
||
.add_event::<FileSelected>()
|
||
.init_resource::<IsoState>()
|
||
.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 them.
|
||
.init_resource::<TextPreview>()
|
||
.init_resource::<PakView>()
|
||
.init_resource::<SkyboxPreview>();
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
app.init_resource::<IsoChannels>()
|
||
.init_resource::<PendingFileBytes>()
|
||
.init_resource::<PendingPak>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
handle_open_iso,
|
||
handle_open_dir,
|
||
handle_file_selected,
|
||
poll_loader_channel,
|
||
apply_loaded_texture,
|
||
apply_pak,
|
||
)
|
||
.chain()
|
||
.in_set(IsoLoaderSystemSet),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Systems (native-only) ─────────────────────────────────────────────────────
|
||
|
||
/// Listens for `RequestOpenIso`, opens a native file-picker in a background
|
||
/// thread, then lists all files in the selected ISO.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn handle_open_iso(
|
||
mut events: EventReader<RequestOpenIso>,
|
||
mut iso_state: ResMut<IsoState>,
|
||
channels: Res<IsoChannels>,
|
||
) {
|
||
let count = events.read().count();
|
||
if count == 0 || iso_state.loading {
|
||
return;
|
||
}
|
||
|
||
iso_state.loading = true;
|
||
iso_state.error = None;
|
||
|
||
let sender = channels.sender.clone();
|
||
std::thread::spawn(move || {
|
||
let path = rfd::FileDialog::new()
|
||
.set_title("Open Xbox 360 Disc Image")
|
||
.add_filter("Xbox 360 ISO", &["iso", "xiso"])
|
||
.pick_file();
|
||
|
||
let Some(path) = path else {
|
||
let _ = sender.send(IsoLoaderMsg::Cancelled);
|
||
return;
|
||
};
|
||
|
||
let label = path.display().to_string();
|
||
let iso_path = path.clone();
|
||
|
||
let result = futures::executor::block_on(async move {
|
||
let mut reader = sylpheed_formats::xiso::open_iso(&path)
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
let files = reader
|
||
.list_all_files()
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
Ok::<_, String>((iso_path, label, files))
|
||
});
|
||
|
||
match result {
|
||
Ok((iso_path, label, files)) => {
|
||
let _ = sender.send(IsoLoaderMsg::FilesListed {
|
||
source: SourceKind::Iso(iso_path),
|
||
label,
|
||
files,
|
||
});
|
||
}
|
||
Err(e) => {
|
||
let _ = sender.send(IsoLoaderMsg::Error(e));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Listens for `RequestOpenDir`, opens a folder picker, lists files via
|
||
/// `GameAssets` (synchronous).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn handle_open_dir(
|
||
mut events: EventReader<RequestOpenDir>,
|
||
mut iso_state: ResMut<IsoState>,
|
||
channels: Res<IsoChannels>,
|
||
) {
|
||
let count = events.read().count();
|
||
if count == 0 || iso_state.loading {
|
||
return;
|
||
}
|
||
|
||
iso_state.loading = true;
|
||
iso_state.error = None;
|
||
|
||
let sender = channels.sender.clone();
|
||
std::thread::spawn(move || {
|
||
let path = rfd::FileDialog::new()
|
||
.set_title("Open Extracted Game Directory")
|
||
.pick_folder();
|
||
|
||
let Some(path) = path else {
|
||
let _ = sender.send(IsoLoaderMsg::Cancelled);
|
||
return;
|
||
};
|
||
|
||
let label = path.display().to_string();
|
||
let assets = sylpheed_formats::vfs::GameAssets::from_directory(&path);
|
||
match assets.list("") {
|
||
Ok(files) => {
|
||
let _ = sender.send(IsoLoaderMsg::FilesListed {
|
||
source: SourceKind::Directory(path),
|
||
label,
|
||
files,
|
||
});
|
||
}
|
||
Err(e) => {
|
||
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Listens for `FileSelected`, reads the file from the active source,
|
||
/// sends bytes back via the channel.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn handle_file_selected(
|
||
mut events: EventReader<FileSelected>,
|
||
iso_state: Res<IsoState>,
|
||
channels: Res<IsoChannels>,
|
||
) {
|
||
for FileSelected(file_path) in events.read() {
|
||
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();
|
||
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 bytes = reader
|
||
.read_file(&file_path)
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
Ok::<_, String>((file_path, bytes))
|
||
});
|
||
|
||
match result {
|
||
Ok((path, bytes)) => {
|
||
let _ = sender
|
||
.send(IsoLoaderMsg::FileLoaded { path, bytes });
|
||
}
|
||
Err(e) => {
|
||
let _ = sender.send(IsoLoaderMsg::Error(e));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
SourceKind::Directory(root) => {
|
||
let assets =
|
||
sylpheed_formats::vfs::GameAssets::from_directory(root);
|
||
match assets.read(&file_path) {
|
||
Ok(bytes) => {
|
||
let _ = sender.send(IsoLoaderMsg::FileLoaded {
|
||
path: file_path,
|
||
bytes,
|
||
});
|
||
}
|
||
Err(e) => {
|
||
let _ = sender.send(IsoLoaderMsg::Error(e.to_string()));
|
||
}
|
||
}
|
||
}
|
||
SourceKind::None => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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"))]
|
||
fn poll_loader_channel(
|
||
channels: Res<IsoChannels>,
|
||
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 {
|
||
use std::sync::mpsc::TryRecvError;
|
||
match receiver.try_recv() {
|
||
Ok(IsoLoaderMsg::FilesListed {
|
||
source,
|
||
label,
|
||
files,
|
||
}) => {
|
||
iso_state.loading = false;
|
||
iso_state.error = None;
|
||
iso_state.source_label = Some(label);
|
||
iso_state.source_kind = source;
|
||
browser.files = files;
|
||
browser.selected = None;
|
||
browser.filter.clear();
|
||
info!("Loaded {} files", browser.files.len());
|
||
}
|
||
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
|
||
pending.path = path;
|
||
pending.bytes = bytes;
|
||
pending.ready = true;
|
||
browser.loading = false;
|
||
}
|
||
Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => {
|
||
pending_pak.name = name;
|
||
pending_pak.rows = rows;
|
||
pending_pak.error = error;
|
||
pending_pak.ready = true;
|
||
browser.loading = false;
|
||
}
|
||
Ok(IsoLoaderMsg::Cancelled) => {
|
||
iso_state.loading = false;
|
||
browser.loading = false;
|
||
}
|
||
Ok(IsoLoaderMsg::Error(msg)) => {
|
||
error!("ISO loader: {}", msg);
|
||
iso_state.loading = false;
|
||
iso_state.error = Some(msg);
|
||
browser.loading = false;
|
||
}
|
||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Free the current texture preview's GPU image + egui slot and reset it.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn free_texture(
|
||
preview: &mut TexturePreview,
|
||
images: &mut Assets<Image>,
|
||
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
|
||
) {
|
||
if let Some(ref handle) = preview.handle {
|
||
contexts.remove_image(handle);
|
||
images.remove(handle.id());
|
||
}
|
||
*preview = TexturePreview::default();
|
||
}
|
||
|
||
/// Free the current skybox preview's per-face GPU images + egui slots and reset it.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn free_skybox(
|
||
skybox: &mut SkyboxPreview,
|
||
images: &mut Assets<Image>,
|
||
contexts: &mut bevy_egui::EguiContexts<'_, '_>,
|
||
) {
|
||
for f in &skybox.faces {
|
||
contexts.remove_image(&f.handle);
|
||
images.remove(f.handle.id());
|
||
}
|
||
*skybox = SkyboxPreview::default();
|
||
}
|
||
|
||
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
|
||
/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn apply_loaded_texture(
|
||
mut pending: ResMut<PendingFileBytes>,
|
||
mut preview: ResMut<TexturePreview>,
|
||
mut text_preview: ResMut<TextPreview>,
|
||
mut pak_view: ResMut<PakView>,
|
||
mut skybox: ResMut<SkyboxPreview>,
|
||
mut file_info: ResMut<FileInfo>,
|
||
mut images: ResMut<Assets<Image>>,
|
||
mut contexts: bevy_egui::EguiContexts,
|
||
) {
|
||
if !pending.ready {
|
||
return;
|
||
}
|
||
pending.ready = false;
|
||
|
||
let bytes = std::mem::take(&mut pending.bytes);
|
||
let path = std::mem::take(&mut pending.path);
|
||
|
||
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
||
|
||
// Always free the previous previews (GPU memory + egui slots) so exactly one
|
||
// viewer is active per selection.
|
||
free_texture(&mut preview, &mut images, &mut contexts);
|
||
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||
*text_preview = TextPreview::default();
|
||
*pak_view = PakView::default();
|
||
|
||
// Populate FileInfo for the status / info panel.
|
||
file_info.name = path
|
||
.split('/')
|
||
.next_back()
|
||
.unwrap_or(&path)
|
||
.to_string();
|
||
file_info.size_bytes = bytes.len();
|
||
file_info.detected_format = Some(fmt);
|
||
|
||
if fmt == FileFormat::Text {
|
||
let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes);
|
||
let truncated = text.len() > MAX_TEXT_BYTES;
|
||
if truncated {
|
||
// Cut on a char boundary so we never split a codepoint.
|
||
let end = text
|
||
.char_indices()
|
||
.take_while(|(i, _)| *i < MAX_TEXT_BYTES)
|
||
.last()
|
||
.map(|(i, c)| i + c.len_utf8())
|
||
.unwrap_or(0);
|
||
text.truncate(end);
|
||
}
|
||
text_preview.content = Some(text);
|
||
text_preview.encoding = encoding.to_string();
|
||
text_preview.truncated = truncated;
|
||
return; // FileInfo + TextPreview are sufficient for text files.
|
||
}
|
||
|
||
if fmt != FileFormat::Xpr2Texture {
|
||
return; // Not a texture — FileInfo is sufficient
|
||
}
|
||
|
||
// World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid.
|
||
// Returns `None` for ordinary 2D textures, which fall through below.
|
||
match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) {
|
||
Ok(Some(cube)) => {
|
||
use sylpheed_formats::texture::{Cubemap, X360Texture};
|
||
for (i, face) in cube.faces.iter().enumerate() {
|
||
let face_tex = X360Texture {
|
||
width: cube.width,
|
||
height: cube.height,
|
||
format: cube.format,
|
||
mip_levels: 1,
|
||
is_cubemap: false,
|
||
data: face.clone(),
|
||
};
|
||
if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) {
|
||
let handle = images.add(img);
|
||
let egui_id = contexts.add_image(handle.clone_weak());
|
||
skybox.faces.push(FaceTex {
|
||
label: Cubemap::face_label(i),
|
||
handle,
|
||
egui_id,
|
||
width: cube.width,
|
||
height: cube.height,
|
||
});
|
||
}
|
||
}
|
||
skybox.info = format!(
|
||
"Cubemap {}×{} {:?} · {} faces",
|
||
cube.width,
|
||
cube.height,
|
||
cube.format,
|
||
skybox.faces.len()
|
||
);
|
||
return;
|
||
}
|
||
Ok(None) => {} // ordinary 2D texture
|
||
Err(e) => {
|
||
preview.format_info = format!("Cubemap parse failed: {e}");
|
||
return;
|
||
}
|
||
}
|
||
|
||
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
preview.format_info = format!("XPR2 parse failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let w = tex.width;
|
||
let h = tex.height;
|
||
let mips = tex.mip_levels;
|
||
let fmt_str = format!("{:?} {}×{} {} mip(s)", tex.format, w, h, mips);
|
||
|
||
let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) {
|
||
Ok(img) => img,
|
||
Err(e) => {
|
||
preview.format_info = format!("Bevy image conversion failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let handle = images.add(image);
|
||
let egui_id = contexts.add_image(handle.clone_weak());
|
||
|
||
preview.handle = Some(handle);
|
||
preview.egui_id = Some(egui_id);
|
||
preview.width = w;
|
||
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 skybox: ResMut<SkyboxPreview>,
|
||
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/skybox + clear the other previews (mirrors the
|
||
// texture path) so exactly one viewer is active.
|
||
free_texture(&mut preview, &mut images, &mut contexts);
|
||
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||
*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(),
|
||
};
|
||
}
|