Files
Sylpheed/crates/sylpheed-viewer/src/iso_loader.rs
MechaCat02 589659bec6 feat(viewer): world cubemap (skybox) viewer — 6-face grid
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>
2026-07-11 13:38:07 +02:00

841 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(),
};
}