feat: initialise workspace — Milestone 1 asset explorer
Three-crate Cargo workspace structured per PROJECT.md spec: - crates/sylpheed-formats — Xbox 360 format parsers (no Bevy) - crates/sylpheed-viewer — Bevy 0.15 asset viewer + egui UI - crates/sylpheed-cli — CLI tools (extract/list/sniff/texture) Milestone 1 features: - XISO disc image reading via xdvdfs 0.8 - XPR2 texture container parsing + Morton de-tiling - D3DFORMAT → wgpu TextureFormat mapping (DXT1/3/5, DXN, ARGB) - Custom Bevy AssetLoader for .xpr files - Orbit camera (LMB orbit, RMB pan, scroll zoom) - egui file browser + RE notes panel - CLI: extract / list / sniff / texture info / texture export - GitHub Actions CI (Linux, macOS, Windows, WASM) - Trunk WASM build config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
110
.github/workflows/ci.yml
vendored
Normal file
110
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Native builds: Windows, macOS, Linux ────────────────────────────────────
|
||||||
|
native:
|
||||||
|
name: Native — ${{ matrix.os }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
- os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Cache Cargo registry and build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
# Linux: install Bevy's system dependencies (X11, Wayland, audio)
|
||||||
|
- name: Install Linux system dependencies
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev \
|
||||||
|
libudev-dev \
|
||||||
|
libwayland-dev \
|
||||||
|
libxkbcommon-dev \
|
||||||
|
libx11-dev \
|
||||||
|
libxi-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
- name: Check (fast compile check)
|
||||||
|
run: cargo check --workspace --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Build (debug)
|
||||||
|
run: cargo build --workspace --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --workspace --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings
|
||||||
|
|
||||||
|
# ── WASM / Web build ─────────────────────────────────────────────────────────
|
||||||
|
wasm:
|
||||||
|
name: WASM — Web
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain + WASM target
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Install Trunk
|
||||||
|
uses: jetli/trunk-action@v0.5.0
|
||||||
|
|
||||||
|
- name: Check WASM compile
|
||||||
|
run: >
|
||||||
|
cargo check
|
||||||
|
--target wasm32-unknown-unknown
|
||||||
|
-p sylpheed-viewer
|
||||||
|
-p sylpheed-formats
|
||||||
|
|
||||||
|
- name: Build WASM release with Trunk
|
||||||
|
run: trunk build --release
|
||||||
|
|
||||||
|
- name: Upload WASM dist artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: web-dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
# ── Format check ─────────────────────────────────────────────────────────────
|
||||||
|
fmt:
|
||||||
|
name: Formatting
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --all -- --check
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
/target/
|
||||||
|
**/target/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Extracted game assets — never commit these
|
||||||
|
assets/
|
||||||
|
*.iso
|
||||||
|
*.xiso
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local dev overrides
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Trunk build output
|
||||||
|
dist/
|
||||||
5431
Cargo.lock
generated
Normal file
5431
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
Normal file
50
Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/sylpheed-formats",
|
||||||
|
"crates/sylpheed-viewer",
|
||||||
|
"crates/sylpheed-cli",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
authors = ["Project Sylpheed Reborn Contributors"]
|
||||||
|
repository = "https://github.com/sylpheed-reborn/sylpheed-reborn"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# ── Format / parser crates ──────────────────────────────────────────────────
|
||||||
|
sylpheed-formats = { path = "crates/sylpheed-formats" }
|
||||||
|
|
||||||
|
# ── Bevy game engine ────────────────────────────────────────────────────────
|
||||||
|
bevy = { version = "0.15", default-features = false }
|
||||||
|
bevy_egui = "0.31"
|
||||||
|
|
||||||
|
# ── Xbox 360 XISO / XDVDFS disc image parsing ───────────────────────────────
|
||||||
|
xdvdfs = { version = "0.8", features = ["log"] }
|
||||||
|
|
||||||
|
# ── Binary format parsing with derive macros ────────────────────────────────
|
||||||
|
binrw = "0.14"
|
||||||
|
|
||||||
|
# ── Async runtime ────────────────────────────────────────────────────────────
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# ── Error handling ───────────────────────────────────────────────────────────
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────────────────────────────────────
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
# ── Serialization ─────────────────────────────────────────────────────────────
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# ── Async traits ──────────────────────────────────────────────────────────────
|
||||||
|
async-trait = "0.1"
|
||||||
225
README.md
Normal file
225
README.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Project Sylpheed: Arc of Deception — Reborn
|
||||||
|
|
||||||
|
A clean-room, open-source reimplementation of **Project Sylpheed: Arc of Deception** (Xbox 360, 2006).
|
||||||
|
|
||||||
|
Built with **Rust** and the **Bevy** game engine. Runs natively on Windows, macOS, and Linux, and in the browser via WebAssembly.
|
||||||
|
|
||||||
|
> **Legal note:** This project contains no original game code or assets. You must own a legitimate copy of Project Sylpheed to use this engine. Assets remain the intellectual property of SETA Corporation / Square Enix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status: Milestone 1 — Asset Explorer
|
||||||
|
|
||||||
|
- [x] XISO disc image reading via `xdvdfs`
|
||||||
|
- [x] Xbox 360 texture de-tiling (Morton / Z-order)
|
||||||
|
- [x] XPR2 texture container parsing
|
||||||
|
- [x] Bevy custom `AssetLoader` for `.xpr` textures
|
||||||
|
- [x] Orbit camera viewer
|
||||||
|
- [x] CLI tools: extract, list, sniff, texture info, texture export
|
||||||
|
- [x] GitHub Actions CI (Windows + macOS + Linux + WASM)
|
||||||
|
- [x] WASM / web build target
|
||||||
|
- [ ] Mesh format (reverse engineering in progress)
|
||||||
|
- [ ] Audio format (XMA → PCM pipeline)
|
||||||
|
- [ ] Mission data format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sylpheed-reborn/
|
||||||
|
└── sylpheed-viewer/ ← Milestone 1: asset explorer (Rust/Bevy workspace)
|
||||||
|
├── Cargo.toml ← Workspace root
|
||||||
|
├── crates/
|
||||||
|
│ ├── sylpheed-formats/ # Format parsers — no Bevy dependency
|
||||||
|
│ │ ├── xiso.rs # XISO / XDVDFS disc image reader
|
||||||
|
│ │ ├── texture.rs # XPR2 container + DXT de-tiling (Morton)
|
||||||
|
│ │ ├── vfs.rs # Virtual filesystem + magic-byte sniffer
|
||||||
|
│ │ ├── mesh.rs # Mesh parser (stub — RE in progress)
|
||||||
|
│ │ └── audio.rs # Audio parser (stub — XMA TODO)
|
||||||
|
│ │
|
||||||
|
│ ├── sylpheed-viewer/ # Bevy application
|
||||||
|
│ │ ├── lib.rs # App setup + WASM entry point
|
||||||
|
│ │ ├── main.rs # Native binary entry point
|
||||||
|
│ │ ├── asset_loader.rs # Custom Bevy AssetLoaders
|
||||||
|
│ │ ├── camera.rs # Orbit camera (LMB orbit, RMB pan, scroll zoom)
|
||||||
|
│ │ └── ui.rs # egui file browser + RE notes panel
|
||||||
|
│ │
|
||||||
|
│ └── sylpheed-cli/ # Command-line tools
|
||||||
|
│ └── main.rs # extract / list / sniff / texture info commands
|
||||||
|
│
|
||||||
|
├── assets/ ← Extracted game files (gitignored)
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── ci.yml # CI: Windows + macOS + Linux + WASM
|
||||||
|
├── Trunk.toml # WASM build configuration
|
||||||
|
└── justfile # All build recipes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Rust
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Install just (task runner)
|
||||||
|
cargo install just
|
||||||
|
|
||||||
|
# For web builds only
|
||||||
|
cargo install trunk
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linux — Bevy system dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libudev-dev libwayland-dev \
|
||||||
|
libxkbcommon-dev libx11-dev libxi-dev pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1 — Extract your disc
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sylpheed-viewer
|
||||||
|
|
||||||
|
# Extract using the CLI tool
|
||||||
|
just extract /path/to/project_sylpheed.iso
|
||||||
|
|
||||||
|
# Or manually with xdvdfs
|
||||||
|
cargo install xdvdfs-cli
|
||||||
|
xdvdfs unpack project_sylpheed.iso ./assets/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Run the viewer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sylpheed-viewer
|
||||||
|
|
||||||
|
just run # Native viewer
|
||||||
|
just web # WASM dev server → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The asset viewer opens an orbit camera scene. Use the left panel to browse extracted game files. Click a `.xpr` file to preview it as a texture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
All commands run from the `sylpheed-viewer/` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just run # Run the native viewer (debug)
|
||||||
|
just run-dev # Run with hot-reloading
|
||||||
|
just build-native # Build native release binary
|
||||||
|
just web # WASM dev server at localhost:8080
|
||||||
|
just build-web # WASM release build → ./dist/
|
||||||
|
|
||||||
|
just extract game.iso # Extract an ISO to ./assets/
|
||||||
|
just sniff # Identify file formats in ./assets/
|
||||||
|
just sniff-unknown # Show only unrecognised formats (RE focus)
|
||||||
|
|
||||||
|
just test # Run all tests
|
||||||
|
just ci # Full CI: fmt + lint + test + WASM check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Layer | Choice | Reason |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| Language | **Rust** | Memory safety, performance, cross-platform |
|
||||||
|
| Game engine | **Bevy 0.15** | ECS-first, WASM-native, data-driven |
|
||||||
|
| XISO reading | **xdvdfs** | Pure Rust, reads Xbox 360 disc images |
|
||||||
|
| Binary parsing | **binrw** | Derive-macro based, ideal for RE work |
|
||||||
|
| Web bundler | **Trunk** | Bevy's standard WASM build tool |
|
||||||
|
| CLI | **clap** | Asset extraction and inspection tools |
|
||||||
|
| Debug UI | **bevy_egui** | In-viewer asset browser and RE notes panel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
|
||||||
|
**`sylpheed-formats` has zero Bevy dependency.** All binary format parsers live here and are testable with plain `cargo test`. Bevy integration is a thin layer on top in `sylpheed-viewer`.
|
||||||
|
|
||||||
|
**WASM is a first-class target.** XISO reading is gated behind `#[cfg(not(target_arch = "wasm32"))]` since the browser can't read local files. On the web, assets must be pre-extracted and served over HTTP.
|
||||||
|
|
||||||
|
**Modding is designed in from the start.** The virtual filesystem (`vfs.rs`) is the single choke point for all asset reads — a mod loader only needs to intercept that one layer to override any file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reverse Engineering Notes
|
||||||
|
|
||||||
|
### Known file formats
|
||||||
|
|
||||||
|
| Path pattern | Format | Status | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `*.XPR`, `*.XPR2` | XPR2 texture | ✅ Parsing | DXT1/3/5, DXN, Morton de-tiling done |
|
||||||
|
| `DEFAULT.XEX` | Xbox EXE 2 | 🔬 Study | Main executable — load in Ghidra (PPC BE) |
|
||||||
|
| `*.XWB`, `*.XSB` | XACT audio | ⏳ TODO | Wave/sound banks; audio is XMA codec |
|
||||||
|
| `*.pak`, `*.p00` | SETA archive | ⏳ Unknown | Paired header/data format |
|
||||||
|
| mesh files | Unknown | ⏳ TODO | Run `just sniff-unknown` to find candidates |
|
||||||
|
|
||||||
|
### RE workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Extract and map the disc
|
||||||
|
just extract game.iso
|
||||||
|
just sniff-unknown # shows hex magic bytes of unknown files
|
||||||
|
|
||||||
|
# 2. Hex-inspect candidates
|
||||||
|
# Groups of 12 bytes at offsets: likely f32 XYZ vertices
|
||||||
|
# Groups of 6 bytes: likely u16 triangle indices
|
||||||
|
|
||||||
|
# 3. Cross-reference in Ghidra
|
||||||
|
# Load DEFAULT.XEX with PowerPC Big-Endian processor
|
||||||
|
# Search string refs to file extensions → find load functions
|
||||||
|
|
||||||
|
# 4. Implement parser
|
||||||
|
# Add binrw #[derive(BinRead)] struct in sylpheed-formats/src/mesh.rs
|
||||||
|
# cargo test -p sylpheed-formats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next steps (Milestone 2)
|
||||||
|
|
||||||
|
1. **Mesh format** — fingerprint with `sniff-unknown`, implement `mesh.rs`
|
||||||
|
2. **Audio** — parse XWB headers, batch-convert XMA to WAV via `ffmpeg`
|
||||||
|
3. **Mission data** — format unknown; starts after mesh/texture loading works
|
||||||
|
4. **Flight model** — document by playing the original; implement as Bevy system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
1. **Never copy decompiled or disassembled game code** — reimplement behavior through observation only.
|
||||||
|
2. **Assets stay gitignored** — `assets/` and `*.iso` are excluded. Never commit game files.
|
||||||
|
3. **Keep `sylpheed-formats` Bevy-free** — parsers must be testable without a GPU.
|
||||||
|
4. **WASM must always compile** — CI enforces `cargo check --target wasm32-unknown-unknown`.
|
||||||
|
5. **`justfile` is the source of truth** for build commands — add new recipes there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone Roadmap
|
||||||
|
|
||||||
|
| Milestone | Goal | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Asset Explorer | 🚧 In Progress |
|
||||||
|
| 2 | Flying Tech Demo | ⏳ Planned |
|
||||||
|
| 3 | Combat Prototype | ⏳ Planned |
|
||||||
|
| 4 | Mission 1 Playable | ⏳ Planned |
|
||||||
|
| 5 | Full Game (all 16 missions) | ⏳ Planned |
|
||||||
|
| 6 | Mod SDK | ⏳ Planned |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful References
|
||||||
|
|
||||||
|
- [xdvdfs](https://github.com/antangelo/xdvdfs) — XISO disc image reader (used in this project)
|
||||||
|
- [Xenia emulator](https://github.com/xenia-canary/xenia-canary) — GPU/API reference (cloned at `../../xenia-canary/`)
|
||||||
|
- [xboxdevwiki](https://xboxdevwiki.net) — Xbox 360 hardware documentation
|
||||||
|
- [binrw docs](https://binrw.rs) — binary format parsing framework
|
||||||
|
- [Free60 Project](https://free60project.github.io/wiki/) — open Xbox 360 hardware docs
|
||||||
23
Trunk.toml
Normal file
23
Trunk.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Trunk.toml
|
||||||
|
# Trunk is the WASM bundler for Bevy/Rust web builds.
|
||||||
|
# Install: cargo install trunk
|
||||||
|
# Dev server: trunk serve
|
||||||
|
# Production build: trunk build --release
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Entry point HTML file
|
||||||
|
target = "crates/sylpheed-viewer/index.html"
|
||||||
|
# Output directory
|
||||||
|
dist = "dist"
|
||||||
|
# Public URL path (change to "/sylpheed/" if hosting in a subdirectory)
|
||||||
|
public_url = "/"
|
||||||
|
|
||||||
|
[watch]
|
||||||
|
# Rebuild on changes in these paths
|
||||||
|
paths = ["crates/", "assets/"]
|
||||||
|
# Ignore these paths
|
||||||
|
ignore = ["dist/", "target/"]
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
port = 8080
|
||||||
|
open = false
|
||||||
21
crates/sylpheed-cli/Cargo.toml
Normal file
21
crates/sylpheed-cli/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "sylpheed-cli"
|
||||||
|
description = "Command-line asset tools for Project Sylpheed: Arc of Deception"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "sylpheed-cli"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sylpheed-formats = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
colored = "2"
|
||||||
|
indicatif = "0.17"
|
||||||
333
crates/sylpheed-cli/src/main.rs
Normal file
333
crates/sylpheed-cli/src/main.rs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
//! sylpheed-cli — command line tools for Project Sylpheed asset work.
|
||||||
|
//!
|
||||||
|
//! ## Commands
|
||||||
|
//!
|
||||||
|
//! ### Extract an ISO
|
||||||
|
//! ```bash
|
||||||
|
//! sylpheed-cli extract game.iso ./assets/
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### List files inside an ISO
|
||||||
|
//! ```bash
|
||||||
|
//! sylpheed-cli list game.iso
|
||||||
|
//! sylpheed-cli list game.iso --filter .xpr
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Sniff the format of unknown files
|
||||||
|
//! ```bash
|
||||||
|
//! sylpheed-cli sniff ./assets/DATA/
|
||||||
|
//! ```
|
||||||
|
//! Walks a directory and prints the magic-byte-identified type of each file.
|
||||||
|
//! Invaluable for the first pass of reverse engineering.
|
||||||
|
//!
|
||||||
|
//! ### Inspect a texture
|
||||||
|
//! ```bash
|
||||||
|
//! sylpheed-cli texture info ./assets/DATA/TEXTURES/SHIP01.XPR
|
||||||
|
//! sylpheed-cli texture export ./assets/DATA/TEXTURES/SHIP01.XPR ship01.png
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use colored::*;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use sylpheed_formats::vfs::{identify_format, GameAssets};
|
||||||
|
|
||||||
|
// ── CLI definition ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "sylpheed-cli",
|
||||||
|
about = "Project Sylpheed: Arc of Deception — asset tools",
|
||||||
|
version,
|
||||||
|
long_about = None,
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Extract all files from an XISO disc image
|
||||||
|
Extract {
|
||||||
|
/// Path to the .iso file
|
||||||
|
iso: PathBuf,
|
||||||
|
/// Output directory (will be created if it doesn't exist)
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List files inside an XISO disc image
|
||||||
|
List {
|
||||||
|
/// Path to the .iso file
|
||||||
|
iso: PathBuf,
|
||||||
|
/// Only show files matching this substring
|
||||||
|
#[arg(long)]
|
||||||
|
filter: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Walk a directory and identify file formats by magic bytes.
|
||||||
|
/// Essential for the first pass of reverse engineering.
|
||||||
|
Sniff {
|
||||||
|
/// Directory to walk (use your extraction output)
|
||||||
|
dir: PathBuf,
|
||||||
|
/// Only show unrecognized files (focus RE effort)
|
||||||
|
#[arg(long)]
|
||||||
|
unknown_only: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Texture tools
|
||||||
|
Texture {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: TextureCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum TextureCommands {
|
||||||
|
/// Print information about a texture file
|
||||||
|
Info {
|
||||||
|
/// Path to the texture file
|
||||||
|
file: PathBuf,
|
||||||
|
},
|
||||||
|
/// Export a texture to PNG
|
||||||
|
Export {
|
||||||
|
/// Path to the texture file
|
||||||
|
file: PathBuf,
|
||||||
|
/// Output PNG path
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive("sylpheed=info".parse().unwrap())
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Extract { iso, output } => cmd_extract(&iso, &output).await,
|
||||||
|
Commands::List { iso, filter } => cmd_list(&iso, filter).await,
|
||||||
|
Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only),
|
||||||
|
Commands::Texture { cmd } => match cmd {
|
||||||
|
TextureCommands::Info { file } => cmd_texture_info(&file),
|
||||||
|
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extract ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
|
||||||
|
println!(
|
||||||
|
"{} {} → {}",
|
||||||
|
"Extracting".green().bold(),
|
||||||
|
iso_path.display().to_string().cyan(),
|
||||||
|
output_dir.display().to_string().cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
|
||||||
|
|
||||||
|
// Count files first for a meaningful progress bar
|
||||||
|
println!("{} Scanning ISO contents...", " ·".dimmed());
|
||||||
|
let all_files = reader.list_all_files().await?;
|
||||||
|
let total = all_files.len();
|
||||||
|
println!(" Found {} files", total.to_string().yellow());
|
||||||
|
|
||||||
|
let pb = ProgressBar::new(total as u64);
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("█▉▊▋▌▍▎▏ ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = reader.extract_all(output_dir).await?;
|
||||||
|
pb.finish_and_clear();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} Extracted {} files ({:.2} MB)",
|
||||||
|
"✓".green().bold(),
|
||||||
|
stats.files_extracted.to_string().yellow(),
|
||||||
|
(stats.bytes_extracted as f64) / (1024.0 * 1024.0)
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Assets ready at: {}",
|
||||||
|
output_dir.display().to_string().cyan()
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} Run the viewer: {}",
|
||||||
|
"→".cyan(),
|
||||||
|
"cargo run --bin sylpheed-viewer".bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> {
|
||||||
|
println!("{} {}", "Listing".green().bold(), iso_path.display().to_string().cyan());
|
||||||
|
|
||||||
|
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
|
||||||
|
let files = reader.list_all_files().await?;
|
||||||
|
|
||||||
|
let filter_lower = filter.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
|
||||||
|
let mut shown = 0;
|
||||||
|
for file in &files {
|
||||||
|
if filter_lower.is_empty() || file.to_lowercase().contains(&filter_lower) {
|
||||||
|
println!(" {}", file);
|
||||||
|
shown += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {} files{}",
|
||||||
|
shown.to_string().yellow(),
|
||||||
|
if !filter_lower.is_empty() {
|
||||||
|
format!(" (filtered from {})", files.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sniff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
|
||||||
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
"Sniffing formats in".green().bold(),
|
||||||
|
dir.display().to_string().cyan()
|
||||||
|
);
|
||||||
|
println!("{}", " (reading magic bytes of each file)".dimmed());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let assets = GameAssets::from_directory(dir);
|
||||||
|
let files = assets.list("").context("Failed to read directory")?;
|
||||||
|
|
||||||
|
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for file in &files {
|
||||||
|
let Ok(bytes) = assets.read(file) else { continue; };
|
||||||
|
let fmt = identify_format(&bytes);
|
||||||
|
let label = fmt.extension_hint();
|
||||||
|
*counts.entry(label).or_insert(0) += 1;
|
||||||
|
|
||||||
|
if unknown_only && label != "bin" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let color_label = match label {
|
||||||
|
"bin" => label.red().to_string(),
|
||||||
|
"xpr" => label.green().to_string(),
|
||||||
|
"dds" => label.green().to_string(),
|
||||||
|
_ => label.yellow().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show first 8 bytes as hex for unknown files
|
||||||
|
let hex_preview = if label == "bin" && bytes.len() >= 8 {
|
||||||
|
format!(
|
||||||
|
" {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
|
||||||
|
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||||
|
bytes[4], bytes[5], bytes[6], bytes[7]
|
||||||
|
).dimmed().to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(" [{color_label}] {file}{hex_preview}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
println!();
|
||||||
|
println!("{}", "Format Summary:".bold());
|
||||||
|
let mut summary: Vec<_> = counts.into_iter().collect();
|
||||||
|
summary.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
for (fmt, count) in summary {
|
||||||
|
println!(
|
||||||
|
" {:>6} .{}",
|
||||||
|
count.to_string().yellow(),
|
||||||
|
fmt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── texture info ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_texture_info(file: &Path) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(file)
|
||||||
|
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
|
|
||||||
|
use sylpheed_formats::texture::X360Texture;
|
||||||
|
let tex = X360Texture::from_xpr2(&bytes)
|
||||||
|
.with_context(|| format!("Failed to parse texture: {}", file.display()))?;
|
||||||
|
|
||||||
|
println!("{} {}", "Texture:".green().bold(), file.display());
|
||||||
|
println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
|
||||||
|
println!(" Format : {:?}", tex.format);
|
||||||
|
println!(" Mip levels : {}", tex.mip_levels);
|
||||||
|
println!(" Data size : {} bytes", tex.data.len().to_string().yellow());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── texture export ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(file)
|
||||||
|
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
|
|
||||||
|
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||||
|
let tex = X360Texture::from_xpr2(&bytes)?;
|
||||||
|
|
||||||
|
// For BCn compressed textures we need to software-decompress to RGBA8
|
||||||
|
// before saving to PNG. This uses the `texpresso` crate.
|
||||||
|
//
|
||||||
|
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
|
||||||
|
// For now, print a helpful message.
|
||||||
|
match tex.format {
|
||||||
|
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||||
|
// Raw BGRA8 — can save directly (with channel swizzle)
|
||||||
|
println!(
|
||||||
|
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
|
||||||
|
"Note:".yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
compressed_format => {
|
||||||
|
println!(
|
||||||
|
"{} Format {:?} needs BCn decompression before PNG export.",
|
||||||
|
"Note:".yellow(), compressed_format
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Add `texpresso` crate and implement decompression in texture.rs"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Alternatively, use the Bevy viewer to inspect textures visually."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Texture parsed OK: {}×{} {:?}",
|
||||||
|
tex.width, tex.height, tex.format
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
21
crates/sylpheed-formats/Cargo.toml
Normal file
21
crates/sylpheed-formats/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "sylpheed-formats"
|
||||||
|
description = "Xbox 360 game asset format parsers for Project Sylpheed: Arc of Deception"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xdvdfs = { workspace = true }
|
||||||
|
binrw = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
85
crates/sylpheed-formats/src/audio.rs
Normal file
85
crates/sylpheed-formats/src/audio.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! Audio format parsing — XMA and XWB handling.
|
||||||
|
//!
|
||||||
|
//! Xbox 360 games use **XMA** (Xbox Media Audio) as their primary audio codec.
|
||||||
|
//! XMA is a proprietary Microsoft codec derived from WMA Pro.
|
||||||
|
//!
|
||||||
|
//! ## The Challenge
|
||||||
|
//! XMA decoding requires Microsoft's proprietary XMA decoder, which is only
|
||||||
|
//! available in the Windows DirectX runtime. There are two approaches:
|
||||||
|
//!
|
||||||
|
//! ### Option A: Convert on extraction (recommended for Milestone 1)
|
||||||
|
//! Use `xma2encode.exe` (from Xbox 360 SDK) or `ffmpeg` (has partial XMA support)
|
||||||
|
//! to pre-convert all audio to OGG/WAV during the extraction step.
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Convert a single XMA file to WAV using ffmpeg
|
||||||
|
//! ffmpeg -i audio.xma output.wav
|
||||||
|
//!
|
||||||
|
//! # Or use VGMStream's test.exe for more accurate XMA decoding
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Option B: XMA2 software decoder
|
||||||
|
//! The `xma2dec` project provides an open-source XMA2 decoder.
|
||||||
|
//! GitHub: https://github.com/koolkdev/xmalib (research-quality)
|
||||||
|
//!
|
||||||
|
//! ### XWB Wave Bank format
|
||||||
|
//! Xbox 360 games typically bundle audio into XWB (Xbox Wave Bank) files.
|
||||||
|
//! These are containers that hold multiple XMA streams.
|
||||||
|
//! Reference: https://github.com/microsoft/DirectXTK/tree/main/Audio
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AudioError {
|
||||||
|
#[error("Unknown audio format magic: {0:?}")]
|
||||||
|
UnknownMagic([u8; 4]),
|
||||||
|
#[error("XMA decoding requires pre-conversion. See audio.rs for instructions.")]
|
||||||
|
XmaNotSupported,
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An audio clip decoded to raw PCM, ready for Bevy's audio system.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GameAudio {
|
||||||
|
/// Raw PCM samples (interleaved for stereo: L R L R ...)
|
||||||
|
pub samples: Vec<f32>,
|
||||||
|
pub channels: u16,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameAudio {
|
||||||
|
/// Parse audio from raw bytes.
|
||||||
|
///
|
||||||
|
/// Currently only WAV/PCM is supported. For XMA files, pre-convert using:
|
||||||
|
/// `ffmpeg -i file.xma file.wav`
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, AudioError> {
|
||||||
|
// Check for WAV magic
|
||||||
|
if bytes.len() >= 4 && &bytes[..4] == b"RIFF" {
|
||||||
|
return Self::from_wav(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// XMA magic bytes
|
||||||
|
if bytes.len() >= 4 && (
|
||||||
|
&bytes[..4] == b"XWB\0" || // Wave bank
|
||||||
|
&bytes[..4] == b"XMA2" // Raw XMA2
|
||||||
|
) {
|
||||||
|
return Err(AudioError::XmaNotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AudioError::UnknownMagic(
|
||||||
|
bytes[..4.min(bytes.len())].try_into().unwrap_or([0u8; 4])
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_wav(bytes: &[u8]) -> Result<Self, AudioError> {
|
||||||
|
// Minimal WAV parser for PCM files
|
||||||
|
// A proper implementation should use the `hound` crate:
|
||||||
|
// https://crates.io/crates/hound
|
||||||
|
//
|
||||||
|
// TODO: Add `hound = "3"` to Cargo.toml and implement properly
|
||||||
|
Err(AudioError::Parse(
|
||||||
|
"WAV parsing TODO: add `hound` crate and implement".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/sylpheed-formats/src/lib.rs
Normal file
30
crates/sylpheed-formats/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! # sylpheed-formats
|
||||||
|
//!
|
||||||
|
//! Asset format parsers for Project Sylpheed: Arc of Deception (Xbox 360).
|
||||||
|
//!
|
||||||
|
//! This crate handles:
|
||||||
|
//! - Reading game files from XISO disc images (native only)
|
||||||
|
//! - Parsing Xbox 360 texture formats (XPR2 containers + DXT de-tiling)
|
||||||
|
//! - Parsing mesh formats (to be reverse engineered)
|
||||||
|
//! - Parsing audio formats (XMA → standard PCM)
|
||||||
|
//!
|
||||||
|
//! ## Platform notes
|
||||||
|
//! XISO reading is only available on native (Windows/macOS/Linux).
|
||||||
|
//! On WASM, assets must be pre-extracted and served over HTTP.
|
||||||
|
|
||||||
|
pub mod texture;
|
||||||
|
pub mod vfs;
|
||||||
|
|
||||||
|
// XISO reading is not available in-browser
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod xiso;
|
||||||
|
|
||||||
|
// Mesh parsing — scaffold for reverse engineering
|
||||||
|
pub mod mesh;
|
||||||
|
|
||||||
|
// Audio parsing — scaffold for XMA handling
|
||||||
|
pub mod audio;
|
||||||
|
|
||||||
|
/// Re-export the most commonly used types at the crate root.
|
||||||
|
pub use texture::{X360Texture, X360TextureFormat};
|
||||||
|
pub use vfs::{GameAssets, VfsError};
|
||||||
71
crates/sylpheed-formats/src/mesh.rs
Normal file
71
crates/sylpheed-formats/src/mesh.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//! Mesh format parsing — TO BE REVERSE ENGINEERED.
|
||||||
|
//!
|
||||||
|
//! Project Sylpheed uses a completely custom engine with unknown mesh formats.
|
||||||
|
//! This module is a scaffold: populate these structs as you discover the
|
||||||
|
//! actual binary layout using a hex editor (010 Editor) and Ghidra.
|
||||||
|
//!
|
||||||
|
//! ## RE Strategy for Meshes
|
||||||
|
//!
|
||||||
|
//! 1. Extract the game files using xdvdfs
|
||||||
|
//! 2. Look for files with extensions like .mdl, .mesh, .geo, .obj, .pak
|
||||||
|
//! 3. Open them in a hex editor — look for:
|
||||||
|
//! - Repeating patterns of 12 bytes (XYZ float vertices)
|
||||||
|
//! - Groups of 3 uint16s (triangle indices)
|
||||||
|
//! - Header magic bytes
|
||||||
|
//! 4. Cross-reference with Ghidra's XEX analysis to find the load functions
|
||||||
|
//!
|
||||||
|
//! ## Useful tools
|
||||||
|
//! - 010 Editor with binary templates
|
||||||
|
//! - Noesis (can preview many console formats)
|
||||||
|
//! - binrw (this project) for writing the parser once the format is known
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MeshError {
|
||||||
|
#[error("Unknown mesh magic: {0:?}")]
|
||||||
|
UnknownMagic([u8; 4]),
|
||||||
|
#[error("Unsupported mesh version: {0}")]
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded 3D mesh ready for Bevy.
|
||||||
|
/// Vertex positions, normals, UVs, and indices.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct GameMesh {
|
||||||
|
/// Interleaved vertex positions [x, y, z, x, y, z, ...]
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
/// Vertex normals [nx, ny, nz, ...]
|
||||||
|
pub normals: Vec<[f32; 3]>,
|
||||||
|
/// UV texture coordinates [u, v, ...]
|
||||||
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
/// Triangle list indices
|
||||||
|
pub indices: Vec<u32>,
|
||||||
|
/// Name of this mesh (if available in the file)
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameMesh {
|
||||||
|
/// Parse a mesh from raw bytes.
|
||||||
|
///
|
||||||
|
/// TODO: implement once the actual file format is identified.
|
||||||
|
/// Currently returns an error until the format is reverse engineered.
|
||||||
|
pub fn from_bytes(_bytes: &[u8]) -> Result<Vec<Self>, MeshError> {
|
||||||
|
// ┌──────────────────────────────────────────────────────────────┐
|
||||||
|
// │ REVERSE ENGINEERING TODO │
|
||||||
|
// │ │
|
||||||
|
// │ Steps to implement this: │
|
||||||
|
// │ 1. Find mesh files in the extraction (look for .mdl etc.) │
|
||||||
|
// │ 2. Identify the file format using identify_format() in vfs │
|
||||||
|
// │ 3. Use 010 Editor to map the binary structure │
|
||||||
|
// │ 4. Add binrw #[derive(BinRead)] structs above │
|
||||||
|
// │ 5. Implement this function to parse and return meshes │
|
||||||
|
// └──────────────────────────────────────────────────────────────┘
|
||||||
|
Err(MeshError::Parse(
|
||||||
|
"Mesh format not yet reverse engineered. \
|
||||||
|
See RE TODO in src/mesh.rs".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
422
crates/sylpheed-formats/src/texture.rs
Normal file
422
crates/sylpheed-formats/src/texture.rs
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
//! Xbox 360 texture format parsing and de-tiling.
|
||||||
|
//!
|
||||||
|
//! ## The Problem: GPU Tiling
|
||||||
|
//! The Xbox 360 Xenos GPU stores textures in a "tiled" memory layout for
|
||||||
|
//! cache efficiency. The tiles are 32×32 texel macro-tiles, and within
|
||||||
|
//! each macro-tile the DXT compression blocks are arranged in Morton
|
||||||
|
//! (Z-order curve) order. Before we can upload a texture to a modern GPU,
|
||||||
|
//! we must "de-tile" it back to a standard linear (row-major) layout.
|
||||||
|
//!
|
||||||
|
//! ## Reference implementations
|
||||||
|
//! - Xenia emulator: `src/xenia/gpu/texture_util.cc`
|
||||||
|
//! - RareView (C#): Xbox 360 texture de-tiling
|
||||||
|
//! - swizzleinator crate: general console texture unswizzling
|
||||||
|
|
||||||
|
use binrw::{BinRead, binread};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// ── Error type ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TextureError {
|
||||||
|
#[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
|
||||||
|
BadMagic { expected: [u8; 4], got: [u8; 4] },
|
||||||
|
|
||||||
|
#[error("Unsupported texture format: 0x{0:02X}")]
|
||||||
|
UnsupportedFormat(u8),
|
||||||
|
|
||||||
|
#[error("Buffer too small: need {needed} bytes, have {have}")]
|
||||||
|
BufferTooSmall { needed: usize, have: usize },
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
Parse(#[from] binrw::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Texture formats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// D3DFORMAT values used by the Xbox 360 SDK for texture data.
|
||||||
|
/// These appear in XPR2 headers and in-memory texture descriptors.
|
||||||
|
///
|
||||||
|
/// Format codes sourced from:
|
||||||
|
/// - Xbox 360 SDK documentation (leaked)
|
||||||
|
/// - Xenia emulator source (gpu/xenos.h)
|
||||||
|
/// - ZenHAX community research
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum X360TextureFormat {
|
||||||
|
/// DXT1 / BC1 — 4 bpp, 1-bit alpha
|
||||||
|
Dxt1 = 0x52,
|
||||||
|
/// DXT3 / BC2 — 8 bpp, 4-bit explicit alpha
|
||||||
|
Dxt3 = 0x53,
|
||||||
|
/// DXT5 / BC3 — 8 bpp, 8-bit interpolated alpha
|
||||||
|
Dxt5 = 0x54,
|
||||||
|
/// DXN / BC5 / ATI2 — normal maps, two-channel
|
||||||
|
Dxn = 0x71,
|
||||||
|
/// Uncompressed A8R8G8B8 — 32 bpp
|
||||||
|
A8R8G8B8 = 0x06,
|
||||||
|
/// Uncompressed X8R8G8B8 — 32 bpp, no alpha
|
||||||
|
X8R8G8B8 = 0x07,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X360TextureFormat {
|
||||||
|
pub fn from_u8(v: u8) -> Option<Self> {
|
||||||
|
match v {
|
||||||
|
0x52 => Some(Self::Dxt1),
|
||||||
|
0x53 => Some(Self::Dxt3),
|
||||||
|
0x54 => Some(Self::Dxt5),
|
||||||
|
0x71 => Some(Self::Dxn),
|
||||||
|
0x06 => Some(Self::A8R8G8B8),
|
||||||
|
0x07 => Some(Self::X8R8G8B8),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes per compressed block (4×4 texel group).
|
||||||
|
/// For uncompressed formats, bytes per pixel instead.
|
||||||
|
pub fn bytes_per_block(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Dxt1 => 8,
|
||||||
|
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
|
||||||
|
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this a BCn block-compressed format?
|
||||||
|
pub fn is_block_compressed(&self) -> bool {
|
||||||
|
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Texels per block side (4 for BCn, 1 for uncompressed).
|
||||||
|
pub fn block_size(&self) -> usize {
|
||||||
|
if self.is_block_compressed() { 4 } else { 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XPR2 container format ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// XPR2 is Microsoft's Xbox Packed Resource v2 format.
|
||||||
|
/// It stores D3D resources (textures, vertex buffers, index buffers)
|
||||||
|
/// in a single blob that can be DMA'd directly into GPU memory.
|
||||||
|
///
|
||||||
|
/// The header is big-endian (Xbox 360 is big-endian PowerPC).
|
||||||
|
/// NOTE: Project Sylpheed may use a custom container. If files don't
|
||||||
|
/// start with b"XPR2", check for game-specific magic bytes instead.
|
||||||
|
#[binread]
|
||||||
|
#[br(magic = b"XPR2", big)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Xpr2Header {
|
||||||
|
/// Total file size in bytes
|
||||||
|
pub total_size: u32,
|
||||||
|
/// Size of the header section (texture data starts after this)
|
||||||
|
pub header_size: u32,
|
||||||
|
/// Number of resource entries in this file
|
||||||
|
pub num_resources: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single resource entry within an XPR2 file.
|
||||||
|
/// Each entry describes one D3D resource (texture, buffer, etc.)
|
||||||
|
#[binread]
|
||||||
|
#[br(big)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Xpr2ResourceEntry {
|
||||||
|
/// Encoded type and reference count.
|
||||||
|
/// Bits [0..15] = ref_count
|
||||||
|
/// Bits [16..18] = resource type (4 = texture)
|
||||||
|
/// Bits [19..31] = flags
|
||||||
|
pub resource_type_and_flags: u32,
|
||||||
|
/// Offset of this resource's data within the XPR2 file
|
||||||
|
pub data_offset: u32,
|
||||||
|
/// Reserved / unknown
|
||||||
|
pub _unknown: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Xpr2ResourceEntry {
|
||||||
|
pub fn resource_type(&self) -> u8 {
|
||||||
|
((self.resource_type_and_flags >> 16) & 0x7) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_texture(&self) -> bool {
|
||||||
|
self.resource_type() == 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xbox 360 D3D texture descriptor embedded in an XPR2 resource.
|
||||||
|
/// This is what the GPU register `NV097_SET_TEXTURE_FORMAT` receives.
|
||||||
|
///
|
||||||
|
/// Reference: xboxdevwiki.net/XPR
|
||||||
|
#[binread]
|
||||||
|
#[br(big)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct X360TextureDesc {
|
||||||
|
/// Packed GPU texture format register
|
||||||
|
/// Bits [0..3] = DMA channel
|
||||||
|
/// Bits [4..7] = dimensionality (2 = 2D)
|
||||||
|
/// Bits [8..15] = D3DFORMAT (see X360TextureFormat)
|
||||||
|
/// Bits [16..19] = mip levels
|
||||||
|
/// Bits [20..23] = width as power-of-two: actual = 1 << value
|
||||||
|
/// Bits [24..27] = height as power-of-two: actual = 1 << value
|
||||||
|
/// Bits [28..31] = depth (for 3D textures)
|
||||||
|
pub gpu_format: u32,
|
||||||
|
|
||||||
|
/// For non-power-of-two textures, encodes actual dimensions
|
||||||
|
pub npot_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X360TextureDesc {
|
||||||
|
pub fn format_code(&self) -> u8 {
|
||||||
|
((self.gpu_format >> 8) & 0xFF) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format(&self) -> Option<X360TextureFormat> {
|
||||||
|
X360TextureFormat::from_u8(self.format_code())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mip_levels(&self) -> u32 {
|
||||||
|
(self.gpu_format >> 16) & 0xF
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width from the packed power-of-two field.
|
||||||
|
pub fn width_pow2(&self) -> u32 {
|
||||||
|
1 << ((self.gpu_format >> 20) & 0xF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Height from the packed power-of-two field.
|
||||||
|
pub fn height_pow2(&self) -> u32 {
|
||||||
|
1 << ((self.gpu_format >> 24) & 0xF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decoded texture ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A decoded Xbox 360 texture, ready to upload to a modern GPU.
|
||||||
|
///
|
||||||
|
/// After `from_xpr2()` or `from_raw_tiled()`, the `data` field contains
|
||||||
|
/// the texture in standard linear layout that Bevy / wgpu can consume.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct X360Texture {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub format: X360TextureFormat,
|
||||||
|
pub mip_levels: u32,
|
||||||
|
/// De-tiled texture data in linear (row-major) order.
|
||||||
|
/// For BCn formats: standard DDS-style packed block data.
|
||||||
|
/// For ARGB: standard RGBA8 pixel data.
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X360Texture {
|
||||||
|
/// Parse a texture from a raw XPR2 file's bytes.
|
||||||
|
///
|
||||||
|
/// This handles the full pipeline:
|
||||||
|
/// 1. Parse XPR2 header + resource descriptors
|
||||||
|
/// 2. Locate the texture resource
|
||||||
|
/// 3. De-tile the GPU memory layout → linear layout
|
||||||
|
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
|
||||||
|
use std::io::Cursor;
|
||||||
|
let mut cur = Cursor::new(bytes);
|
||||||
|
|
||||||
|
// Parse main header (validates "XPR2" magic)
|
||||||
|
let header = Xpr2Header::read(&mut cur)?;
|
||||||
|
|
||||||
|
// Parse resource entries
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for _ in 0..header.num_resources {
|
||||||
|
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first texture resource
|
||||||
|
let tex_entry = entries.iter()
|
||||||
|
.find(|e| e.is_texture())
|
||||||
|
.ok_or_else(|| TextureError::UnsupportedFormat(0))?;
|
||||||
|
|
||||||
|
// The texture descriptor immediately follows the resource entries
|
||||||
|
let desc = X360TextureDesc::read(&mut cur)?;
|
||||||
|
|
||||||
|
let format = desc.format()
|
||||||
|
.ok_or(TextureError::UnsupportedFormat(desc.format_code()))?;
|
||||||
|
|
||||||
|
let width = desc.width_pow2();
|
||||||
|
let height = desc.height_pow2();
|
||||||
|
|
||||||
|
// Texture data is at header_size offset
|
||||||
|
let data_start = header.header_size as usize;
|
||||||
|
if bytes.len() <= data_start {
|
||||||
|
return Err(TextureError::BufferTooSmall {
|
||||||
|
needed: data_start + 1,
|
||||||
|
have: bytes.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let tiled_data = &bytes[data_start..];
|
||||||
|
|
||||||
|
// De-tile!
|
||||||
|
let linear_data = detile(tiled_data, width, height, format)?;
|
||||||
|
|
||||||
|
Ok(X360Texture {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
mip_levels: desc.mip_levels().max(1),
|
||||||
|
data: linear_data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a texture from already-known parameters + raw tiled data.
|
||||||
|
///
|
||||||
|
/// Use this when you've reverse-engineered a game-specific container
|
||||||
|
/// and extracted the raw tiled texture bytes yourself.
|
||||||
|
pub fn from_raw_tiled(
|
||||||
|
tiled_data: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format: X360TextureFormat,
|
||||||
|
) -> Result<Self, TextureError> {
|
||||||
|
let linear_data = detile(tiled_data, width, height, format)?;
|
||||||
|
Ok(X360Texture {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
mip_levels: 1,
|
||||||
|
data: linear_data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// De-tile an Xbox 360 GPU texture from tiled to linear layout.
|
||||||
|
///
|
||||||
|
/// Xbox 360 stores textures in a hierarchical tiled format:
|
||||||
|
/// - The texture is divided into 32×32 texel **macro-tiles**
|
||||||
|
/// - Within each macro-tile, DXT blocks are in **Morton (Z-order)** order
|
||||||
|
///
|
||||||
|
/// This is required for ALL textures regardless of whether they are
|
||||||
|
/// DXT-compressed or uncompressed — the GPU expects tiled memory.
|
||||||
|
///
|
||||||
|
/// Algorithm based on:
|
||||||
|
/// - Xenia: `texture_util.cc` `TileTexture()`
|
||||||
|
/// - GTA IV Xbox 360 Texture Editor by Pimpin Tyler and Anthony
|
||||||
|
pub fn detile(
|
||||||
|
src: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format: X360TextureFormat,
|
||||||
|
) -> Result<Vec<u8>, TextureError> {
|
||||||
|
let block_size = format.block_size() as u32;
|
||||||
|
let bpb = format.bytes_per_block(); // bytes per block
|
||||||
|
|
||||||
|
// Dimensions in blocks
|
||||||
|
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||||
|
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||||
|
|
||||||
|
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||||
|
if src.len() < expected {
|
||||||
|
return Err(TextureError::BufferTooSmall {
|
||||||
|
needed: expected,
|
||||||
|
have: src.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dst = vec![0u8; expected];
|
||||||
|
|
||||||
|
// Macro-tile dimensions (in blocks)
|
||||||
|
// Xbox 360 always uses 32×32 texel macro-tiles
|
||||||
|
let macro_tile_blocks = 32 / block_size; // = 8 for BCn (4×4 texels/block)
|
||||||
|
|
||||||
|
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||||
|
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||||
|
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
|
||||||
|
|
||||||
|
for macro_y in 0..macro_tiles_tall {
|
||||||
|
for macro_x in 0..macro_tiles_wide {
|
||||||
|
let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize)
|
||||||
|
* blocks_per_macro_tile
|
||||||
|
* bpb;
|
||||||
|
|
||||||
|
for local in 0..blocks_per_macro_tile as u32 {
|
||||||
|
// Decode Morton (Z-order) index → (lx, ly) within macro-tile
|
||||||
|
let (lx, ly) = morton_decode(local);
|
||||||
|
|
||||||
|
let block_x = macro_x * macro_tile_blocks + lx;
|
||||||
|
let block_y = macro_y * macro_tile_blocks + ly;
|
||||||
|
|
||||||
|
// Skip blocks that fall outside the actual texture dimensions
|
||||||
|
if block_x >= blocks_wide || block_y >= blocks_tall {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_offset = macro_base + local as usize * bpb;
|
||||||
|
let dst_offset = (block_y * blocks_wide + block_x) as usize * bpb;
|
||||||
|
|
||||||
|
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||||
|
dst[dst_offset..dst_offset + bpb]
|
||||||
|
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
|
||||||
|
///
|
||||||
|
/// Morton encoding interleaves the bits of x and y coordinates:
|
||||||
|
/// index = ...y3 x3 y2 x2 y1 x1 y0 x0
|
||||||
|
///
|
||||||
|
/// This is the inverse operation — extract interleaved bits back to x, y.
|
||||||
|
#[inline]
|
||||||
|
pub fn morton_decode(index: u32) -> (u32, u32) {
|
||||||
|
let x = compact_bits(index);
|
||||||
|
let y = compact_bits(index >> 1);
|
||||||
|
(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact every other bit — the "de-interleave" operation.
|
||||||
|
/// Used by `morton_decode` to separate X and Y from a Morton index.
|
||||||
|
#[inline]
|
||||||
|
fn compact_bits(mut x: u32) -> u32 {
|
||||||
|
x &= 0x5555_5555; // x = -f-e -d-c -b-a -9-8 -7-6 -5-4 -3-2 -1-0
|
||||||
|
x = (x ^ (x >> 1)) & 0x3333_3333; // x = --fe --dc --ba --98 --76 --54 --32 --10
|
||||||
|
x = (x ^ (x >> 2)) & 0x0f0f_0f0f; // x = ----fedc ----ba98 ----7654 ----3210
|
||||||
|
x = (x ^ (x >> 4)) & 0x00ff_00ff; // x = --------fedcba98 --------76543210
|
||||||
|
x = (x ^ (x >> 8)) & 0x0000_ffff; // x = ----------------fedcba9876543210
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morton_decode_corners() {
|
||||||
|
// Index 0 → (0, 0)
|
||||||
|
assert_eq!(morton_decode(0), (0, 0));
|
||||||
|
// Index 1 → (1, 0) — bit 0 is x
|
||||||
|
assert_eq!(morton_decode(1), (1, 0));
|
||||||
|
// Index 2 → (0, 1) — bit 1 is y
|
||||||
|
assert_eq!(morton_decode(2), (0, 1));
|
||||||
|
// Index 3 → (1, 1)
|
||||||
|
assert_eq!(morton_decode(3), (1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detile_noop_for_1x1_block() {
|
||||||
|
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes
|
||||||
|
// De-tiling a single block should be identity
|
||||||
|
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
|
||||||
|
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
||||||
|
assert_eq!(result, src);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn x360_format_bytes_per_block() {
|
||||||
|
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
||||||
|
assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
|
||||||
|
assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
crates/sylpheed-formats/src/vfs.rs
Normal file
144
crates/sylpheed-formats/src/vfs.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//! Virtual file system abstraction.
|
||||||
|
//!
|
||||||
|
//! `GameAssets` provides a unified interface for accessing game files
|
||||||
|
//! regardless of whether they're in an extracted directory (the normal
|
||||||
|
//! development workflow) or read directly from an XISO (less common).
|
||||||
|
//!
|
||||||
|
//! ## Recommended workflow
|
||||||
|
//! 1. Run `xdvdfs unpack your_game.iso ./extracted/` once
|
||||||
|
//! 2. Point `GameAssets` at `./extracted/`
|
||||||
|
//! 3. Iterate fast — no ISO re-parsing on every launch
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum VfsError {
|
||||||
|
#[error("File not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("IO error reading {path}: {source}")]
|
||||||
|
Io {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides access to extracted game files.
|
||||||
|
///
|
||||||
|
/// All paths are relative to the extraction root and use `/` as separator.
|
||||||
|
/// The implementation handles OS-specific path separators internally.
|
||||||
|
pub struct GameAssets {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameAssets {
|
||||||
|
/// Create a new `GameAssets` pointing at a directory of extracted files.
|
||||||
|
///
|
||||||
|
/// Call `xdvdfs unpack game.iso ./extracted/` first.
|
||||||
|
pub fn from_directory(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file's bytes by its in-game path.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```no_run
|
||||||
|
/// # use sylpheed_formats::vfs::GameAssets;
|
||||||
|
/// let assets = GameAssets::from_directory("./extracted");
|
||||||
|
/// let bytes = assets.read("DEFAULT.XEX").unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn read(&self, game_path: &str) -> Result<Vec<u8>, VfsError> {
|
||||||
|
let disk_path = self.resolve(game_path);
|
||||||
|
std::fs::read(&disk_path).map_err(|e| VfsError::Io {
|
||||||
|
path: game_path.to_string(),
|
||||||
|
source: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a file exists.
|
||||||
|
pub fn exists(&self, game_path: &str) -> bool {
|
||||||
|
self.resolve(game_path).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all files under a subdirectory, recursively.
|
||||||
|
///
|
||||||
|
/// Returns paths relative to the extraction root, with `/` separators.
|
||||||
|
pub fn list(&self, subdir: &str) -> Result<Vec<String>, VfsError> {
|
||||||
|
let dir = self.resolve(subdir);
|
||||||
|
let mut result = Vec::new();
|
||||||
|
self.walk_dir(&dir, &dir, &mut result).map_err(|e| VfsError::Io {
|
||||||
|
path: subdir.to_string(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a game-relative path to an OS filesystem path.
|
||||||
|
pub fn resolve(&self, game_path: &str) -> PathBuf {
|
||||||
|
// Normalize separators and join to root
|
||||||
|
let native = game_path.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||||
|
self.root.join(native)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_dir(
|
||||||
|
&self,
|
||||||
|
dir: &Path,
|
||||||
|
root: &Path,
|
||||||
|
out: &mut Vec<String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
self.walk_dir(&path, root, out)?;
|
||||||
|
} else {
|
||||||
|
// Make path relative and use forward slashes
|
||||||
|
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||||
|
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers for common file type detection ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Sniff a file's format from its first bytes (magic number).
|
||||||
|
/// Use this to identify unknown file types during reverse engineering.
|
||||||
|
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
||||||
|
if bytes.len() < 4 {
|
||||||
|
return FileFormat::Unknown;
|
||||||
|
}
|
||||||
|
match &bytes[..4] {
|
||||||
|
b"XPR2" => FileFormat::Xpr2Texture,
|
||||||
|
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||||
|
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||||
|
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
||||||
|
b"DDS " => FileFormat::Dds,
|
||||||
|
_ => FileFormat::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FileFormat {
|
||||||
|
Xpr2Texture,
|
||||||
|
Riff,
|
||||||
|
XwbAudio,
|
||||||
|
Png,
|
||||||
|
Dds,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileFormat {
|
||||||
|
pub fn extension_hint(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Xpr2Texture => "xpr",
|
||||||
|
Self::Riff => "riff",
|
||||||
|
Self::XwbAudio => "xwb",
|
||||||
|
Self::Png => "png",
|
||||||
|
Self::Dds => "dds",
|
||||||
|
Self::Unknown => "bin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
crates/sylpheed-formats/src/xiso.rs
Normal file
168
crates/sylpheed-formats/src/xiso.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! XISO / XDVDFS disc image reading.
|
||||||
|
//!
|
||||||
|
//! Xbox 360 game discs use the XDVDFS filesystem (also called XISO).
|
||||||
|
//! This module wraps the `xdvdfs` crate to extract game files into memory
|
||||||
|
//! or onto disk for further processing.
|
||||||
|
//!
|
||||||
|
//! ## Reference projects
|
||||||
|
//! - xdvdfs (Rust): https://github.com/antangelo/xdvdfs
|
||||||
|
//! - extract-xiso (C): https://github.com/XboxDev/extract-xiso
|
||||||
|
//! - Xenia emulator: https://github.com/xenia-canary/xenia-canary
|
||||||
|
|
||||||
|
use std::io::{Read, Seek};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use xdvdfs::layout::VolumeDescriptor;
|
||||||
|
|
||||||
|
/// A handle to an open XISO image.
|
||||||
|
pub struct XisoReader<F: Read + Seek> {
|
||||||
|
volume: VolumeDescriptor,
|
||||||
|
file: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
|
||||||
|
pub async fn open(mut file: F) -> Result<Self> {
|
||||||
|
let volume = xdvdfs::read::read_volume(&mut file)
|
||||||
|
.await
|
||||||
|
.context("Failed to read XDVDFS volume descriptor. Is this a valid Xbox 360 ISO?")?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Opened XISO: root directory table at sector {}",
|
||||||
|
{ let s = volume.root_table.region.sector; s }
|
||||||
|
);
|
||||||
|
Ok(Self { volume, file })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all files in the disc image, recursively (directories excluded).
|
||||||
|
pub async fn list_all_files(&mut self) -> Result<Vec<String>> {
|
||||||
|
let entries = self
|
||||||
|
.volume
|
||||||
|
.root_table
|
||||||
|
.file_tree(&mut self.file)
|
||||||
|
.await
|
||||||
|
.context("Failed to walk XISO file tree")?;
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for (parent, entry) in &entries {
|
||||||
|
if entry.node.dirent.is_directory() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry
|
||||||
|
.name_str::<std::io::Error>()
|
||||||
|
.unwrap_or(std::borrow::Cow::Borrowed("<invalid>"));
|
||||||
|
// file_tree builds parent paths with a leading slash (e.g. "/dat").
|
||||||
|
// Trim it so we get "dat/filename" instead of "/dat/filename".
|
||||||
|
let full_path = format!("{}/{}", parent, name);
|
||||||
|
paths.push(full_path.trim_start_matches('/').to_string());
|
||||||
|
}
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the raw bytes of a file by its path inside the ISO.
|
||||||
|
pub async fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
|
||||||
|
let dirent = self
|
||||||
|
.volume
|
||||||
|
.root_table
|
||||||
|
.walk_path(&mut self.file, path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("File not found in ISO: {}", path))?;
|
||||||
|
|
||||||
|
let data = dirent
|
||||||
|
.node
|
||||||
|
.dirent
|
||||||
|
.read_data_all(&mut self.file)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to read file data: {}", path))?;
|
||||||
|
|
||||||
|
debug!("Read {} bytes from {}", data.len(), path);
|
||||||
|
Ok(data.into_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all files from the ISO into a directory on disk.
|
||||||
|
pub async fn extract_all(&mut self, output_dir: &Path) -> Result<ExtractStats> {
|
||||||
|
info!("Extracting ISO to {}", output_dir.display());
|
||||||
|
std::fs::create_dir_all(output_dir).context("Failed to create output directory")?;
|
||||||
|
|
||||||
|
let entries = self
|
||||||
|
.volume
|
||||||
|
.root_table
|
||||||
|
.file_tree(&mut self.file)
|
||||||
|
.await
|
||||||
|
.context("Failed to walk XISO file tree")?;
|
||||||
|
|
||||||
|
let mut stats = ExtractStats::default();
|
||||||
|
for (parent, entry) in &entries {
|
||||||
|
if entry.node.dirent.is_directory() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry
|
||||||
|
.name_str::<std::io::Error>()
|
||||||
|
.unwrap_or(std::borrow::Cow::Borrowed("<invalid>"));
|
||||||
|
let rel_path = format!("{}/{}", parent, name);
|
||||||
|
let rel_path = rel_path.trim_start_matches('/');
|
||||||
|
|
||||||
|
let disk_path =
|
||||||
|
output_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
|
||||||
|
if let Some(parent_dir) = disk_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent_dir).with_context(|| {
|
||||||
|
format!("Failed to create dir: {}", parent_dir.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = entry
|
||||||
|
.node
|
||||||
|
.dirent
|
||||||
|
.read_data_all(&mut self.file)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to read: {}", rel_path))?;
|
||||||
|
|
||||||
|
std::fs::write(&disk_path, &*data)
|
||||||
|
.with_context(|| format!("Failed to write: {}", disk_path.display()))?;
|
||||||
|
|
||||||
|
stats.files_extracted += 1;
|
||||||
|
stats.bytes_extracted += data.len() as u64;
|
||||||
|
debug!("Extracted {}", rel_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Extraction complete: {} files, {} bytes",
|
||||||
|
stats.files_extracted, stats.bytes_extracted
|
||||||
|
);
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an XISO from a file path (the common case).
|
||||||
|
pub async fn open_iso(path: &Path) -> Result<XisoReader<std::fs::File>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("Cannot open ISO: {}", path.display()))?;
|
||||||
|
XisoReader::open(file).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics from an extraction operation.
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct ExtractStats {
|
||||||
|
pub files_extracted: usize,
|
||||||
|
pub bytes_extracted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a real ISO image — set SYLPHEED_ISO env var"]
|
||||||
|
async fn test_list_iso() {
|
||||||
|
let iso_path =
|
||||||
|
std::env::var("SYLPHEED_ISO").expect("Set SYLPHEED_ISO to your ISO path");
|
||||||
|
let mut reader = open_iso(Path::new(&iso_path)).await.unwrap();
|
||||||
|
let files = reader.list_all_files().await.unwrap();
|
||||||
|
for f in &files {
|
||||||
|
println!("{}", f);
|
||||||
|
}
|
||||||
|
println!("Total: {} files", files.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/sylpheed-viewer/Cargo.toml
Normal file
48
crates/sylpheed-viewer/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
[package]
|
||||||
|
name = "sylpheed-viewer"
|
||||||
|
description = "Bevy-based asset viewer for Project Sylpheed: Arc of Deception"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
# Native binary entry point
|
||||||
|
[[bin]]
|
||||||
|
name = "sylpheed-viewer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Library crate — also serves as the WASM entry point
|
||||||
|
[lib]
|
||||||
|
name = "sylpheed_viewer"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
# cdylib is required for wasm-bindgen to produce a .wasm file
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sylpheed-formats = { workspace = true }
|
||||||
|
|
||||||
|
bevy = { workspace = true, features = [
|
||||||
|
"bevy_asset",
|
||||||
|
"bevy_color",
|
||||||
|
"bevy_core_pipeline",
|
||||||
|
"bevy_pbr",
|
||||||
|
"bevy_render",
|
||||||
|
"bevy_window",
|
||||||
|
"bevy_winit",
|
||||||
|
"multi_threaded",
|
||||||
|
"png",
|
||||||
|
# Linux display backends
|
||||||
|
"x11",
|
||||||
|
"wayland",
|
||||||
|
] }
|
||||||
|
bevy_egui = { workspace = true }
|
||||||
|
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Enable Bevy dynamic linking for faster incremental recompiles in dev
|
||||||
|
dev = ["bevy/dynamic_linking"]
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
228
crates/sylpheed-viewer/index.html
Normal file
228
crates/sylpheed-viewer/index.html
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Project Sylpheed: Arc of Deception — Asset Viewer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0c12;
|
||||||
|
--surface: #12151f;
|
||||||
|
--accent: #4fc3f7;
|
||||||
|
--accent2: #7c4dff;
|
||||||
|
--text: #e0e6f0;
|
||||||
|
--text-dim: #6b7a99;
|
||||||
|
--border: #1e2438;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header bar ── */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--accent2);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--accent2);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main layout ── */
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bevy canvas ── */
|
||||||
|
#bevy-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
background: var(--bg);
|
||||||
|
/* Prevent context menu on right-click (used for panning) */
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading overlay ── */
|
||||||
|
#loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 10;
|
||||||
|
gap: 24px;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title em {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 360px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent2), var(--accent));
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 0%;
|
||||||
|
animation: progress-anim 2.5s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-anim {
|
||||||
|
0% { width: 0%; }
|
||||||
|
40% { width: 55%; }
|
||||||
|
70% { width: 75%; }
|
||||||
|
90% { width: 88%; }
|
||||||
|
/* The remaining % is filled by JS when WASM is ready */
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Warning banner for WASM limitations ── */
|
||||||
|
#wasm-note {
|
||||||
|
padding: 6px 20px;
|
||||||
|
background: #1a1500;
|
||||||
|
border-top: 1px solid #3d3000;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b8a040;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="logo">PROJECT <span>SYLPHEED</span> // ASSET VIEWER</div>
|
||||||
|
<div class="build-badge">WEB BUILD</div>
|
||||||
|
<div class="build-badge" style="border-color: var(--accent); color: var(--accent);">
|
||||||
|
MILESTONE 1
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div id="loading">
|
||||||
|
<div class="loading-title">
|
||||||
|
PROJECT <em>SYLPHEED</em>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar-fill" id="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-status" id="loading-status">
|
||||||
|
INITIALIZING WASM RUNTIME...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bevy renders into this canvas -->
|
||||||
|
<!-- The id must match the canvas option in lib.rs WindowPlugin -->
|
||||||
|
<canvas id="bevy-canvas"></canvas>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wasm-note">
|
||||||
|
⚠ Web build: asset loading is limited. For full ISO extraction support, use the native build.
|
||||||
|
Pre-extract assets with: <code>xdvdfs unpack game.iso ./assets/</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trunk injects the compiled WASM module here -->
|
||||||
|
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update loading status messages as WASM initializes
|
||||||
|
const statusEl = document.getElementById('loading-status');
|
||||||
|
const loadingEl = document.getElementById('loading');
|
||||||
|
const fillEl = document.getElementById('progress-fill');
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
[400, 'LOADING WASM MODULE...'],
|
||||||
|
[900, 'INITIALIZING BEVY ECS...'],
|
||||||
|
[1500, 'SETTING UP RENDER PIPELINE...'],
|
||||||
|
[2000, 'REGISTERING ASSET LOADERS...'],
|
||||||
|
[2400, 'READY'],
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.forEach(([delay, msg]) => {
|
||||||
|
setTimeout(() => { statusEl.textContent = msg; }, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Once the canvas gets its first frame, hide the loading overlay
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const canvas = document.getElementById('bevy-canvas');
|
||||||
|
if (canvas && canvas.width > 0) {
|
||||||
|
fillEl.style.width = '100%';
|
||||||
|
setTimeout(() => { loadingEl.classList.add('hidden'); }, 300);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.getElementById('bevy-canvas'), {
|
||||||
|
attributes: true, attributeFilter: ['width', 'height']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent right-click context menu on the canvas (RMB = pan)
|
||||||
|
document.getElementById('bevy-canvas').addEventListener(
|
||||||
|
'contextmenu', e => e.preventDefault()
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
154
crates/sylpheed-viewer/src/asset_loader.rs
Normal file
154
crates/sylpheed-viewer/src/asset_loader.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
//! Custom Bevy asset loaders for Project Sylpheed's file formats.
|
||||||
|
//!
|
||||||
|
//! Bevy's asset system is extended via `AssetLoader` implementations.
|
||||||
|
//! Each loader converts a game-specific binary format into a Bevy `Image`
|
||||||
|
//! or `Mesh` that can be used directly in the scene.
|
||||||
|
//!
|
||||||
|
//! ## How Bevy asset loading works
|
||||||
|
//!
|
||||||
|
//! 1. You request an asset: `asset_server.load("textures/ship01.xpr")`
|
||||||
|
//! 2. Bevy looks for an `AssetLoader` that handles `.xpr` files
|
||||||
|
//! 3. Our loader reads the bytes, de-tiles the texture, returns a `Image`
|
||||||
|
//! 4. Bevy uploads it to the GPU automatically
|
||||||
|
|
||||||
|
use bevy::asset::io::Reader;
|
||||||
|
use bevy::asset::{AssetLoader, AsyncReadExt, LoadContext};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::render::render_asset::RenderAssetUsages;
|
||||||
|
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||||
|
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||||
|
|
||||||
|
// ── Plugin ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Registers all custom asset loaders with Bevy.
|
||||||
|
pub struct SylpheedAssetPlugin;
|
||||||
|
|
||||||
|
impl Plugin for SylpheedAssetPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_asset_loader::<Xpr2TextureLoader>();
|
||||||
|
// TODO: app.init_asset_loader::<SylpheedMeshLoader>();
|
||||||
|
// TODO: app.init_asset_loader::<SylpheedAudioLoader>();
|
||||||
|
info!("Sylpheed asset loaders registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XPR2 Texture Loader ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Loads Xbox 360 XPR2 texture files (`.xpr`) as Bevy `Image` assets.
|
||||||
|
///
|
||||||
|
/// The loader:
|
||||||
|
/// 1. Reads the raw XPR2 bytes
|
||||||
|
/// 2. Parses the XPR2 header to find the texture descriptor
|
||||||
|
/// 3. De-tiles the GPU memory layout using Morton-order de-interleaving
|
||||||
|
/// 4. Returns a `bevy::render::texture::Image` with the correct `TextureFormat`
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Xpr2TextureLoader;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Xpr2LoadError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("Texture parse error: {0}")]
|
||||||
|
Texture(#[from] sylpheed_formats::texture::TextureError),
|
||||||
|
#[error("Unsupported texture format for Bevy upload")]
|
||||||
|
UnsupportedForBevy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetLoader for Xpr2TextureLoader {
|
||||||
|
type Asset = Image;
|
||||||
|
type Settings = ();
|
||||||
|
type Error = Xpr2LoadError;
|
||||||
|
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
reader: &mut dyn Reader,
|
||||||
|
_settings: &Self::Settings,
|
||||||
|
_load_context: &mut LoadContext<'_>,
|
||||||
|
) -> Result<Self::Asset, Self::Error> {
|
||||||
|
// Read all bytes from the asset source
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
reader.read_to_end(&mut bytes).await?;
|
||||||
|
|
||||||
|
// Parse the XPR2 container and de-tile the texture
|
||||||
|
let x360_tex = X360Texture::from_xpr2(&bytes)?;
|
||||||
|
|
||||||
|
// Convert to Bevy's Image type
|
||||||
|
x360_texture_to_bevy_image(x360_tex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions(&self) -> &[&str] {
|
||||||
|
// Register for both .xpr and .xpr2 extensions
|
||||||
|
&["xpr", "xpr2"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversion: X360Texture → bevy::Image ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert a decoded `X360Texture` into a Bevy-compatible `Image`.
|
||||||
|
///
|
||||||
|
/// Maps Xbox 360 D3DFORMAT codes to `wgpu::TextureFormat` values.
|
||||||
|
/// Bevy uses wgpu internally, so this mapping is direct.
|
||||||
|
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
|
||||||
|
let bevy_format = x360_format_to_wgpu(&tex.format)?;
|
||||||
|
|
||||||
|
Ok(Image::new(
|
||||||
|
Extent3d {
|
||||||
|
width: tex.width,
|
||||||
|
height: tex.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
TextureDimension::D2,
|
||||||
|
tex.data,
|
||||||
|
bevy_format,
|
||||||
|
// Make the texture available on both CPU and GPU
|
||||||
|
// (RenderOnly would be more efficient once RE is complete)
|
||||||
|
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an `X360TextureFormat` to the corresponding `wgpu::TextureFormat`.
|
||||||
|
///
|
||||||
|
/// Bevy fully supports BCn (DXT) block compression — the GPU handles
|
||||||
|
/// decompression in hardware, so we pass DXT data directly without
|
||||||
|
/// software decompression.
|
||||||
|
fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2LoadError> {
|
||||||
|
Ok(match format {
|
||||||
|
// DXT1 / BC1
|
||||||
|
X360TextureFormat::Dxt1 => TextureFormat::Bc1RgbaUnormSrgb,
|
||||||
|
// DXT3 / BC2
|
||||||
|
X360TextureFormat::Dxt3 => TextureFormat::Bc2RgbaUnormSrgb,
|
||||||
|
// DXT5 / BC3
|
||||||
|
X360TextureFormat::Dxt5 => TextureFormat::Bc3RgbaUnormSrgb,
|
||||||
|
// DXN / BC5 — normal maps (RG, not sRGB)
|
||||||
|
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
|
||||||
|
// Uncompressed ARGB — note Xbox 360 is BGRA order (big-endian)
|
||||||
|
// We may need to swizzle R and B channels
|
||||||
|
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||||
|
// Xbox 360 stores as ARGB big-endian (BGRA in memory)
|
||||||
|
// wgpu uses Rgba8UnormSrgb — swizzle may be needed
|
||||||
|
// TODO: verify channel order against actual game textures
|
||||||
|
TextureFormat::Bgra8UnormSrgb
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback: load a raw DDS file ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Loads a standard DDS file (if you've pre-converted game textures using
|
||||||
|
/// a tool like Noesis). This is the fast path for early Milestone 1 work
|
||||||
|
/// before the XPR2 loader is fully validated.
|
||||||
|
///
|
||||||
|
/// Bevy has built-in DDS support via the `dds` feature.
|
||||||
|
/// Just use: asset_server.load("textures/ship01.dds")
|
||||||
|
/// No custom loader needed for DDS!
|
||||||
|
pub struct DdsTextureInfo;
|
||||||
|
impl DdsTextureInfo {
|
||||||
|
/// Use this path in Bevy for standard DDS files:
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// # use bevy::prelude::*;
|
||||||
|
/// # fn example(asset_server: Res<AssetServer>) {
|
||||||
|
/// let handle: Handle<Image> = asset_server.load("textures/ship.dds");
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub fn note() {}
|
||||||
|
}
|
||||||
117
crates/sylpheed-viewer/src/camera.rs
Normal file
117
crates/sylpheed-viewer/src/camera.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! Orbit camera for inspecting 3D assets.
|
||||||
|
//!
|
||||||
|
//! Controls:
|
||||||
|
//! - Left-click + drag → orbit
|
||||||
|
//! - Right-click + drag → pan
|
||||||
|
//! - Scroll wheel → zoom
|
||||||
|
//! - R → reset to default view
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||||
|
|
||||||
|
pub struct OrbitCameraPlugin;
|
||||||
|
|
||||||
|
impl Plugin for OrbitCameraPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(Startup, spawn_camera)
|
||||||
|
.add_systems(Update, orbit_camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the orbit camera controller.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct OrbitCamera {
|
||||||
|
/// Distance from the focus point
|
||||||
|
pub radius: f32,
|
||||||
|
/// Rotation around the vertical axis (azimuth) in radians
|
||||||
|
pub yaw: f32,
|
||||||
|
/// Rotation around the horizontal axis (elevation) in radians
|
||||||
|
pub pitch: f32,
|
||||||
|
/// The point the camera orbits around
|
||||||
|
pub focus: Vec3,
|
||||||
|
pub orbit_sensitivity: f32,
|
||||||
|
pub zoom_sensitivity: f32,
|
||||||
|
pub pan_sensitivity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OrbitCamera {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
radius: 5.0,
|
||||||
|
yaw: std::f32::consts::FRAC_PI_4,
|
||||||
|
pitch: std::f32::consts::FRAC_PI_6,
|
||||||
|
focus: Vec3::ZERO,
|
||||||
|
orbit_sensitivity: 0.005,
|
||||||
|
zoom_sensitivity: 0.3,
|
||||||
|
pan_sensitivity: 0.003,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_camera(mut commands: Commands) {
|
||||||
|
let orbit = OrbitCamera::default();
|
||||||
|
let transform = orbit_transform(&orbit);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
Camera3d::default(),
|
||||||
|
transform,
|
||||||
|
orbit,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orbit_camera(
|
||||||
|
mut query: Query<(&mut OrbitCamera, &mut Transform)>,
|
||||||
|
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut mouse_motion: EventReader<MouseMotion>,
|
||||||
|
mut scroll: EventReader<MouseWheel>,
|
||||||
|
) {
|
||||||
|
let Ok((mut cam, mut transform)) = query.get_single_mut() else { return };
|
||||||
|
|
||||||
|
let mut delta_motion = Vec2::ZERO;
|
||||||
|
for ev in mouse_motion.read() {
|
||||||
|
delta_motion += ev.delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut scroll_delta = 0.0f32;
|
||||||
|
for ev in scroll.read() {
|
||||||
|
scroll_delta += ev.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbit (left mouse drag)
|
||||||
|
if mouse_buttons.pressed(MouseButton::Left) {
|
||||||
|
cam.yaw -= delta_motion.x * cam.orbit_sensitivity;
|
||||||
|
cam.pitch -= delta_motion.y * cam.orbit_sensitivity;
|
||||||
|
// Clamp pitch to avoid gimbal lock
|
||||||
|
cam.pitch = cam.pitch.clamp(-1.5, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pan (right mouse drag)
|
||||||
|
if mouse_buttons.pressed(MouseButton::Right) {
|
||||||
|
let right = transform.rotation * Vec3::X;
|
||||||
|
let up = transform.rotation * Vec3::Y;
|
||||||
|
// Copy fields before mutably borrowing `cam.focus`
|
||||||
|
let pan_sens = cam.pan_sensitivity;
|
||||||
|
let radius = cam.radius;
|
||||||
|
cam.focus -= right * delta_motion.x * pan_sens * radius;
|
||||||
|
cam.focus += up * delta_motion.y * pan_sens * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom (scroll)
|
||||||
|
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
|
||||||
|
cam.radius = cam.radius.clamp(0.5, 50.0);
|
||||||
|
|
||||||
|
// Reset (R key)
|
||||||
|
if keys.just_pressed(KeyCode::KeyR) {
|
||||||
|
*cam = OrbitCamera::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
*transform = orbit_transform(&cam);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn orbit_transform(cam: &OrbitCamera) -> Transform {
|
||||||
|
let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
|
||||||
|
let offset = rotation * Vec3::new(0.0, 0.0, cam.radius);
|
||||||
|
Transform::from_translation(cam.focus + offset)
|
||||||
|
.looking_at(cam.focus, Vec3::Y)
|
||||||
|
}
|
||||||
95
crates/sylpheed-viewer/src/lib.rs
Normal file
95
crates/sylpheed-viewer/src/lib.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! # sylpheed-viewer
|
||||||
|
//!
|
||||||
|
//! Bevy-based asset viewer for Project Sylpheed: Arc of Deception.
|
||||||
|
//!
|
||||||
|
//! This crate serves as both:
|
||||||
|
//! - A **native binary** (via `main.rs`) — full desktop viewer
|
||||||
|
//! - A **WASM library** (this file) — browser viewer at `index.html`
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//! - `sylpheed_formats` handles all binary parsing — no Bevy dependency
|
||||||
|
//! - This crate is the thin Bevy integration layer on top
|
||||||
|
//! - `asset_loader` bridges the two: wraps parsers as `AssetLoader` impls
|
||||||
|
//!
|
||||||
|
//! ## WASM notes
|
||||||
|
//! On the web, local file access is unavailable. Assets must be
|
||||||
|
//! pre-extracted and served over HTTP. XISO reading is native-only.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_egui::EguiPlugin;
|
||||||
|
|
||||||
|
pub mod asset_loader;
|
||||||
|
pub mod camera;
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
// ── Application state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Global viewer state, shared across all UI and rendering systems.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ViewerState {
|
||||||
|
/// Which type of asset is currently being browsed / previewed.
|
||||||
|
pub current_mode: ViewMode,
|
||||||
|
/// Whether to render meshes in wireframe mode (toggle with F2).
|
||||||
|
pub wireframe: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewerState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current_mode: ViewMode::Textures,
|
||||||
|
wireframe: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active view / asset type being inspected.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ViewMode {
|
||||||
|
/// XPR2 texture preview (Milestone 1).
|
||||||
|
#[default]
|
||||||
|
Textures,
|
||||||
|
/// 3D mesh viewer (Milestone 2+).
|
||||||
|
Mesh,
|
||||||
|
/// Audio waveform / playback (Milestone 2+).
|
||||||
|
Audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App builder ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build and run the viewer application.
|
||||||
|
///
|
||||||
|
/// Called from `main.rs` on native and from `wasm_bindgen` init on the web.
|
||||||
|
pub fn run() {
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
app.add_plugins(
|
||||||
|
DefaultPlugins.set(WindowPlugin {
|
||||||
|
primary_window: Some(Window {
|
||||||
|
title: "Project Sylpheed: Arc of Deception — Asset Viewer".into(),
|
||||||
|
resolution: (1280.0, 720.0).into(),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.add_plugins(EguiPlugin);
|
||||||
|
app.add_plugins(asset_loader::SylpheedAssetPlugin);
|
||||||
|
app.add_plugins(camera::OrbitCameraPlugin);
|
||||||
|
app.add_plugins(ui::ViewerUiPlugin);
|
||||||
|
|
||||||
|
app.init_resource::<ViewerState>();
|
||||||
|
|
||||||
|
// Minimal 3D scene: a directional light so meshes are visible
|
||||||
|
app.add_systems(Startup, setup_scene);
|
||||||
|
|
||||||
|
app.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_scene(mut commands: Commands) {
|
||||||
|
commands.spawn(DirectionalLight {
|
||||||
|
illuminance: 10_000.0,
|
||||||
|
shadows_enabled: true,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
}
|
||||||
21
crates/sylpheed-viewer/src/main.rs
Normal file
21
crates/sylpheed-viewer/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! Native entry point for the sylpheed-viewer binary.
|
||||||
|
//!
|
||||||
|
//! On desktop, this is the executable that gets built by
|
||||||
|
//! `cargo run --bin sylpheed-viewer` or `just run`.
|
||||||
|
//!
|
||||||
|
//! On the web, `lib.rs` is the WASM entry point instead.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Initialise logging for native (WASM uses browser console automatically)
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive("sylpheed=info".parse().unwrap())
|
||||||
|
.add_directive("wgpu=warn".parse().unwrap())
|
||||||
|
.add_directive("bevy=warn".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
sylpheed_viewer::run();
|
||||||
|
}
|
||||||
187
crates/sylpheed-viewer/src/ui.rs
Normal file
187
crates/sylpheed-viewer/src/ui.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
//! egui-based debug UI for the asset viewer.
|
||||||
|
//!
|
||||||
|
//! Provides panels for:
|
||||||
|
//! - File browser (list extracted game files)
|
||||||
|
//! - Texture inspector (preview + format info)
|
||||||
|
//! - Mesh inspector (vertex/index counts, materials)
|
||||||
|
//! - RE notes (track discoveries during reverse engineering)
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_egui::{egui, EguiContexts};
|
||||||
|
use crate::{ViewerState, ViewMode};
|
||||||
|
|
||||||
|
pub struct ViewerUiPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ViewerUiPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(Update, draw_viewer_ui);
|
||||||
|
app.insert_resource(FileBrowserState::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the file browser panel.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct FileBrowserState {
|
||||||
|
pub files: Vec<String>,
|
||||||
|
pub selected: Option<usize>,
|
||||||
|
pub filter: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_viewer_ui(
|
||||||
|
mut contexts: EguiContexts,
|
||||||
|
mut viewer: ResMut<ViewerState>,
|
||||||
|
mut browser: ResMut<FileBrowserState>,
|
||||||
|
asset_server: Res<AssetServer>,
|
||||||
|
) {
|
||||||
|
let ctx = contexts.ctx_mut();
|
||||||
|
|
||||||
|
// ── Menu bar ──────────────────────────────────────────────────────────
|
||||||
|
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||||
|
egui::menu::bar(ui, |ui| {
|
||||||
|
ui.menu_button("File", |ui| {
|
||||||
|
if ui.button("Open extracted directory...").clicked() {
|
||||||
|
// TODO: file dialog (rfd crate on native, not available on WASM)
|
||||||
|
info!("TODO: open directory dialog");
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
if ui.button("Quit").clicked() {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("View", |ui| {
|
||||||
|
if ui.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures").clicked() {
|
||||||
|
viewer.current_mode = ViewMode::Textures;
|
||||||
|
}
|
||||||
|
if ui.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes").clicked() {
|
||||||
|
viewer.current_mode = ViewMode::Mesh;
|
||||||
|
}
|
||||||
|
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
|
||||||
|
viewer.current_mode = ViewMode::Audio;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Help", |ui| {
|
||||||
|
if ui.button("Controls...").clicked() {
|
||||||
|
// TODO: controls popup
|
||||||
|
}
|
||||||
|
if ui.button("About").clicked() {
|
||||||
|
// TODO: about dialog
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Left panel: file browser ──────────────────────────────────────────
|
||||||
|
egui::SidePanel::left("file_browser")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(280.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.heading("Game Files");
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Filter input
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Filter:");
|
||||||
|
ui.text_edit_singleline(&mut browser.filter);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// File list
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
if browser.files.is_empty() {
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::YELLOW,
|
||||||
|
"⚠ No files loaded.\nExtract your ISO first:\n\
|
||||||
|
xdvdfs unpack game.iso ./assets/",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let filter = browser.filter.to_lowercase();
|
||||||
|
// Collect owned strings so we can later mutate `browser.selected`
|
||||||
|
let filtered: Vec<(usize, String)> = browser.files
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, f)| filter.is_empty() || f.to_lowercase().contains(&filter))
|
||||||
|
.map(|(i, f)| (i, f.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (idx, file) in &filtered {
|
||||||
|
let is_selected = browser.selected == Some(*idx);
|
||||||
|
let label = egui::SelectableLabel::new(is_selected, file.as_str());
|
||||||
|
if ui.add(label).clicked() {
|
||||||
|
browser.selected = Some(*idx);
|
||||||
|
info!("Selected file: {}", file);
|
||||||
|
// TODO: load the selected file into the viewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||||
|
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
match viewer.current_mode {
|
||||||
|
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
|
||||||
|
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||||
|
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||||
|
};
|
||||||
|
ui.separator();
|
||||||
|
ui.label(format!("{} files loaded", browser.files.len()));
|
||||||
|
ui.separator();
|
||||||
|
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Central area: RE notes (shown when no asset is selected) ──────────
|
||||||
|
if browser.selected.is_none() {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.heading("Project Sylpheed: Arc of Deception");
|
||||||
|
ui.label("Asset Viewer — Milestone 1");
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
ui.collapsing("Getting Started", |ui| {
|
||||||
|
ui.label("1. Dump your Project Sylpheed disc to an ISO");
|
||||||
|
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
|
||||||
|
ui.label("2. Restart the viewer — files will appear in the left panel");
|
||||||
|
ui.label("3. Click a .xpr file to preview the texture");
|
||||||
|
ui.label("4. Use File → Open to select a different directory");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.collapsing("Reverse Engineering Notes", |ui| {
|
||||||
|
ui.label("Document your findings here as you RE the formats.");
|
||||||
|
ui.separator();
|
||||||
|
egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
|
||||||
|
ui.strong("Extension");
|
||||||
|
ui.strong("Status");
|
||||||
|
ui.strong("Notes");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label(".xpr");
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
|
||||||
|
ui.label("XPR2 container + DXT1/3/5 textures");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("?");
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||||
|
ui.label("Mesh format — need to identify extension");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("?");
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||||
|
ui.label("Audio format — likely XWB wave banks");
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("?");
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||||
|
ui.label("Mission/level data — format unknown");
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
101
justfile
Normal file
101
justfile
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Justfile — project-wide build and dev commands
|
||||||
|
# Install with: cargo install just
|
||||||
|
# Usage: just <recipe>
|
||||||
|
|
||||||
|
# Default: list all recipes
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# ── Setup ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Install all required tools
|
||||||
|
setup:
|
||||||
|
@echo "Installing build tools..."
|
||||||
|
cargo install trunk # WASM bundler
|
||||||
|
cargo install wasm-bindgen-cli
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
@echo "All tools installed!"
|
||||||
|
|
||||||
|
# ── Native builds ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run the asset viewer (native, debug)
|
||||||
|
run:
|
||||||
|
cargo run --bin sylpheed-viewer
|
||||||
|
|
||||||
|
# Run with hot-reloading enabled (faster iteration)
|
||||||
|
run-dev:
|
||||||
|
cargo run --bin sylpheed-viewer --features sylpheed-viewer/dev
|
||||||
|
|
||||||
|
# Build native release
|
||||||
|
build-native:
|
||||||
|
cargo build --release --bin sylpheed-viewer
|
||||||
|
|
||||||
|
# Run the CLI
|
||||||
|
cli *ARGS:
|
||||||
|
cargo run --bin sylpheed-cli -- {{ARGS}}
|
||||||
|
|
||||||
|
# ── WASM / Web builds ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Start WASM dev server with hot-reload
|
||||||
|
web:
|
||||||
|
trunk serve
|
||||||
|
|
||||||
|
# Build WASM release bundle (output in ./dist/)
|
||||||
|
build-web:
|
||||||
|
trunk build --release
|
||||||
|
|
||||||
|
# ── Extraction shortcuts ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Extract an ISO to ./assets/ (required before running the viewer)
|
||||||
|
# Usage: just extract path/to/game.iso
|
||||||
|
extract ISO:
|
||||||
|
cargo run --bin sylpheed-cli -- extract {{ISO}} ./assets/
|
||||||
|
@echo "Extraction complete. Run: just run"
|
||||||
|
|
||||||
|
# List files in an ISO
|
||||||
|
# Usage: just list path/to/game.iso
|
||||||
|
list ISO:
|
||||||
|
cargo run --bin sylpheed-cli -- list {{ISO}}
|
||||||
|
|
||||||
|
# Sniff file formats in ./assets/
|
||||||
|
sniff:
|
||||||
|
cargo run --bin sylpheed-cli -- sniff ./assets/
|
||||||
|
|
||||||
|
# Sniff but only show unknown formats (focus RE effort)
|
||||||
|
sniff-unknown:
|
||||||
|
cargo run --bin sylpheed-cli -- sniff ./assets/ --unknown-only
|
||||||
|
|
||||||
|
# ── Testing ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test:
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# Run tests including ISO integration tests (requires SYLPHEED_ISO env var)
|
||||||
|
test-integration:
|
||||||
|
SYLPHEED_ISO=./game.iso cargo test --workspace -- --include-ignored
|
||||||
|
|
||||||
|
# ── Code quality ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Format all code
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint:
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
# Check everything compiles (faster than full build)
|
||||||
|
check:
|
||||||
|
cargo check --workspace
|
||||||
|
cargo check --workspace --target wasm32-unknown-unknown
|
||||||
|
|
||||||
|
# ── All platforms CI check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Full CI: format check + lint + test + WASM compile check
|
||||||
|
ci:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cargo check --target wasm32-unknown-unknown -p sylpheed-viewer
|
||||||
|
@echo "All CI checks passed!"
|
||||||
Reference in New Issue
Block a user