feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,61 +0,0 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resource payload that can be either borrowed from mapped bytes or owned.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResourceData<'a> {
|
||||
Borrowed(&'a [u8]),
|
||||
Owned(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<'a> ResourceData<'a> {
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice,
|
||||
Self::Owned(buf) => buf.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_owned(self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice.to_vec(),
|
||||
Self::Owned(buf) => buf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for ResourceData<'_> {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sink used by `read_into`/`load_into` APIs.
|
||||
pub trait OutputBuffer {
|
||||
/// Writes the full payload to the sink, replacing any previous content.
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
impl OutputBuffer for Vec<u8> {
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.clear();
|
||||
self.extend_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collects all files under `root`.
|
||||
pub fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files_recursive(&path, out);
|
||||
} else if path.is_file() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-animation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "fparkan-assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,481 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Asset manager ports and transactional preparation models.
|
||||
|
||||
use fparkan_material::{decode_wear, resolve_material, WEAR_KIND};
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
||||
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
||||
use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
|
||||
use fparkan_resource::{ResourceKey, ResourceRepository};
|
||||
use fparkan_texm::decode_texm;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
const TEXTURES_ARCHIVE: &str = "textures.lib";
|
||||
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
|
||||
|
||||
/// Stable typed identifier for a prepared asset.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct AssetId<T> {
|
||||
raw: u64,
|
||||
marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> AssetId<T> {
|
||||
/// Creates an asset id from a stable raw value.
|
||||
#[must_use]
|
||||
pub const fn new(raw: u64) -> Self {
|
||||
Self {
|
||||
raw,
|
||||
marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable raw id.
|
||||
#[must_use]
|
||||
pub const fn raw(self) -> u64 {
|
||||
self.raw
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU-side data needed before a visual can be handed to a renderer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PreparedVisual {
|
||||
/// Stable id derived from the prototype geometry key.
|
||||
pub id: AssetId<PreparedVisual>,
|
||||
/// Optional mesh resource backing the visual.
|
||||
pub mesh: Option<ResourceKey>,
|
||||
/// Number of validated model nodes.
|
||||
pub model_nodes: usize,
|
||||
/// Number of validated material slots on the model.
|
||||
pub model_slots: usize,
|
||||
/// Number of validated render batches.
|
||||
pub model_batches: usize,
|
||||
/// Number of WEAR material slots resolved through MAT0.
|
||||
pub material_count: usize,
|
||||
/// Number of texture phase requests decoded as TEXM.
|
||||
pub texture_count: usize,
|
||||
/// Number of lightmap requests decoded as TEXM.
|
||||
pub lightmap_count: usize,
|
||||
}
|
||||
|
||||
/// A transactional mission asset preparation plan.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MissionAssetPlan {
|
||||
/// Number of visual prototypes in the plan.
|
||||
pub visual_count: usize,
|
||||
/// Number of mesh-backed visuals.
|
||||
pub model_count: usize,
|
||||
/// Number of material slot requests.
|
||||
pub material_count: usize,
|
||||
/// Number of texture phase requests.
|
||||
pub texture_count: usize,
|
||||
/// Number of lightmap requests.
|
||||
pub lightmap_count: usize,
|
||||
}
|
||||
|
||||
/// Coarse CPU-side asset budgets.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct AssetBudgets {
|
||||
/// Bytes parsed from source resource payloads.
|
||||
pub parsed_bytes: u64,
|
||||
}
|
||||
|
||||
/// Errors raised while preparing CPU-side assets.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AssetError {
|
||||
/// A required cross-resource dependency was not found.
|
||||
MissingDependency(String),
|
||||
/// A prototype did not describe a usable visual.
|
||||
InvalidPrototype(String),
|
||||
/// A repository operation failed.
|
||||
Resource(String),
|
||||
/// MSH parsing or validation failed.
|
||||
Msh(String),
|
||||
/// WEAR/MAT0 parsing or resolution failed.
|
||||
Material(String),
|
||||
/// TEXM parsing failed.
|
||||
Texture(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AssetError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingDependency(value) => write!(f, "missing dependency: {value}"),
|
||||
Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"),
|
||||
Self::Resource(value) => write!(f, "resource error: {value}"),
|
||||
Self::Msh(value) => write!(f, "msh error: {value}"),
|
||||
Self::Material(value) => write!(f, "material error: {value}"),
|
||||
Self::Texture(value) => write!(f, "texture error: {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AssetError {}
|
||||
|
||||
/// Port implemented by typed asset loaders.
|
||||
pub trait AssetLoader<T> {
|
||||
/// Loads an asset for the given resource key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] when the resource cannot be resolved or decoded.
|
||||
fn load(&self, key: &ResourceKey) -> Result<Arc<T>, AssetError>;
|
||||
}
|
||||
|
||||
/// Minimal asset manager façade over an immutable resource repository.
|
||||
#[derive(Debug)]
|
||||
pub struct AssetManager<R> {
|
||||
repository: R,
|
||||
}
|
||||
|
||||
impl<R> AssetManager<R> {
|
||||
/// Creates a manager backed by the given repository.
|
||||
#[must_use]
|
||||
pub const fn new(repository: R) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
/// Returns the backing repository.
|
||||
#[must_use]
|
||||
pub const fn repository(&self) -> &R {
|
||||
&self.repository
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ResourceRepository> AssetManager<R> {
|
||||
/// Prepares one prototype visual using the manager repository.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] if any model, material, texture, or lightmap
|
||||
/// dependency is missing or malformed.
|
||||
pub fn prepare_visual(&self, proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
|
||||
prepare_visual_with_repository(&self.repository, proto)
|
||||
}
|
||||
|
||||
/// Builds a mission plan by preparing each resolved prototype.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] if any visual dependency is missing or malformed.
|
||||
pub fn build_mission_asset_plan<'a>(
|
||||
&self,
|
||||
prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
|
||||
) -> Result<MissionAssetPlan, AssetError> {
|
||||
build_mission_asset_plan_with_repository(&self.repository, prototypes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a count-only plan from a prototype graph.
|
||||
#[must_use]
|
||||
pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan {
|
||||
MissionAssetPlan {
|
||||
visual_count: graph.prototype_requests.len(),
|
||||
..MissionAssetPlan::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a fully validated CPU-side mission asset plan.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] if any reachable visual dependency is missing or
|
||||
/// malformed.
|
||||
pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>(
|
||||
repository: &R,
|
||||
prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
|
||||
) -> Result<MissionAssetPlan, AssetError> {
|
||||
let mut plan = MissionAssetPlan::default();
|
||||
let mut prepared_visuals = BTreeSet::new();
|
||||
|
||||
for proto in prototypes {
|
||||
let visual_id = stable_visual_id(proto);
|
||||
if !prepared_visuals.insert(visual_id) {
|
||||
continue;
|
||||
}
|
||||
let visual = prepare_visual_with_repository(repository, proto)?;
|
||||
plan.visual_count += 1;
|
||||
if visual.mesh.is_some() {
|
||||
plan.model_count += 1;
|
||||
}
|
||||
plan.material_count += visual.material_count;
|
||||
plan.texture_count += visual.texture_count;
|
||||
plan.lightmap_count += visual.lightmap_count;
|
||||
}
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Validates a prototype visual without resolving cross-resource dependencies.
|
||||
///
|
||||
/// This is useful for tests and API callers that only need a stable visual id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] when the prototype geometry is malformed.
|
||||
pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
|
||||
let id = stable_visual_id(proto);
|
||||
let mesh = match &proto.geometry {
|
||||
PrototypeGeometry::Mesh(key) => Some(key.clone()),
|
||||
PrototypeGeometry::NonGeometric => None,
|
||||
};
|
||||
|
||||
Ok(PreparedVisual {
|
||||
id: AssetId::new(id),
|
||||
mesh,
|
||||
model_nodes: 0,
|
||||
model_slots: 0,
|
||||
model_batches: 0,
|
||||
material_count: 0,
|
||||
texture_count: 0,
|
||||
lightmap_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prepares one visual and validates all CPU-side resource dependencies.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AssetError`] if the model, WEAR table, MAT0 materials, texture
|
||||
/// phases, or lightmaps cannot be resolved and decoded.
|
||||
pub fn prepare_visual_with_repository<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
proto: &EffectivePrototype,
|
||||
) -> Result<PreparedVisual, AssetError> {
|
||||
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
|
||||
return prepare_visual(proto);
|
||||
};
|
||||
|
||||
let nres = decode_nres(
|
||||
read_key(repository, mesh_key, Some("mesh"))?,
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| AssetError::Msh(err.to_string()))?;
|
||||
let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?;
|
||||
let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?;
|
||||
|
||||
let wear_name = sibling_name(mesh_key, "wea")?;
|
||||
let wear_key = ResourceKey {
|
||||
archive: mesh_key.archive.clone(),
|
||||
name: wear_name,
|
||||
type_id: Some(WEAR_KIND),
|
||||
};
|
||||
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
|
||||
.map_err(|err| AssetError::Material(err.to_string()))?;
|
||||
|
||||
let mut material_count = 0;
|
||||
let mut texture_count = 0;
|
||||
let mut lightmap_count = 0;
|
||||
for material_index in 0..wear.entries.len() {
|
||||
let material_index = u16::try_from(material_index).map_err(|_| {
|
||||
AssetError::Material("material index does not fit archive format".to_string())
|
||||
})?;
|
||||
let material = resolve_material(repository, &wear, material_index)
|
||||
.map_err(|err| AssetError::Material(err.to_string()))?;
|
||||
material_count += 1;
|
||||
|
||||
for texture in material.document.texture_requests() {
|
||||
resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?;
|
||||
texture_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for lightmap in &wear.lightmaps {
|
||||
resolve_texm(
|
||||
repository,
|
||||
&lightmap.lightmap,
|
||||
&[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
|
||||
)?;
|
||||
lightmap_count += 1;
|
||||
}
|
||||
|
||||
Ok(PreparedVisual {
|
||||
id: AssetId::new(stable_visual_id(proto)),
|
||||
mesh: Some(mesh_key.clone()),
|
||||
model_nodes: model.node_count,
|
||||
model_slots: model.slots.len(),
|
||||
model_batches: model.batches.len(),
|
||||
material_count,
|
||||
texture_count,
|
||||
lightmap_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_key<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
key: &ResourceKey,
|
||||
label: Option<&str>,
|
||||
) -> Result<Arc<[u8]>, AssetError> {
|
||||
let handle = repository
|
||||
.open_archive(&key.archive)
|
||||
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
|
||||
.and_then(|archive| {
|
||||
repository
|
||||
.find(archive, &key.name)
|
||||
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
|
||||
})?
|
||||
.ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?;
|
||||
let bytes = repository
|
||||
.read(handle)
|
||||
.map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
fn resolve_texm<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
name: &ResourceName,
|
||||
archives: &[&str],
|
||||
) -> Result<(), AssetError> {
|
||||
for archive in archives {
|
||||
let key = ResourceKey {
|
||||
archive: parse_path(archive)?,
|
||||
name: name.clone(),
|
||||
type_id: None,
|
||||
};
|
||||
match read_key(repository, &key, Some("texm")) {
|
||||
Ok(bytes) => {
|
||||
decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(AssetError::MissingDependency(format!("{name:?}")))
|
||||
}
|
||||
|
||||
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
|
||||
let dot = key
|
||||
.name
|
||||
.0
|
||||
.iter()
|
||||
.rposition(|byte| *byte == b'.')
|
||||
.ok_or_else(|| {
|
||||
AssetError::InvalidPrototype(format!("resource name has no extension: {:?}", key.name))
|
||||
})?;
|
||||
let mut name = key.name.0[..dot].to_vec();
|
||||
name.push(b'.');
|
||||
name.extend_from_slice(extension.as_bytes());
|
||||
Ok(ResourceName(name))
|
||||
}
|
||||
|
||||
fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
|
||||
let mut hasher = StableHasher::default();
|
||||
match &proto.geometry {
|
||||
PrototypeGeometry::Mesh(key) => {
|
||||
1_u8.hash(&mut hasher);
|
||||
key.archive.as_str().hash(&mut hasher);
|
||||
key.name.0.hash(&mut hasher);
|
||||
key.type_id.hash(&mut hasher);
|
||||
}
|
||||
PrototypeGeometry::NonGeometric => {
|
||||
0_u8.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn parse_path(value: &str) -> Result<NormalizedPath, AssetError> {
|
||||
normalize_relative(value.as_bytes(), PathPolicy::HostCompatible)
|
||||
.map_err(|err| AssetError::InvalidPrototype(format!("{err}")))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StableHasher(u64);
|
||||
|
||||
impl Hasher for StableHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let mut value = if self.0 == 0 {
|
||||
0xcbf2_9ce4_8422_2325
|
||||
} else {
|
||||
self.0
|
||||
};
|
||||
for byte in bytes {
|
||||
value ^= u64::from(*byte);
|
||||
value = value.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
self.0 = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_prototype::build_prototype_graph;
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn count_only_plan_uses_graph_requests() {
|
||||
let graph = PrototypeGraph::default();
|
||||
|
||||
let plan = build_mission_asset_plan(&graph);
|
||||
|
||||
assert_eq!(plan.visual_count, 0);
|
||||
assert_eq!(plan.model_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_real_unit_asset_plan() {
|
||||
let root = fixture_root("IS");
|
||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||
let repository = CachedResourceRepository::new(Arc::clone(&vfs));
|
||||
let roots = [resource_name(b"UNITS/AUTO/swlklas.dat")];
|
||||
|
||||
let (graph, prototypes) =
|
||||
build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
|
||||
let count_only = build_mission_asset_plan(&graph);
|
||||
let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
|
||||
.expect("asset preparation");
|
||||
|
||||
assert_eq!(count_only.visual_count, 12);
|
||||
assert_eq!(prototypes.len(), 12);
|
||||
assert_eq!(plan.visual_count, 11);
|
||||
assert_eq!(plan.model_count, 11);
|
||||
assert_eq!(plan.material_count, 62);
|
||||
assert_eq!(plan.texture_count, 77);
|
||||
assert_eq!(plan.lightmap_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
|
||||
let root = fixture_root("IS");
|
||||
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
||||
let repository = CachedResourceRepository::new(Arc::clone(&vfs));
|
||||
let roots = [
|
||||
resource_name(b"UNITS/AUTO/swlklas.dat"),
|
||||
resource_name(b"UNITS/AUTO/swlklas.dat"),
|
||||
];
|
||||
|
||||
let (graph, prototypes) =
|
||||
build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
|
||||
let count_only = build_mission_asset_plan(&graph);
|
||||
let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
|
||||
.expect("asset preparation");
|
||||
|
||||
assert_eq!(graph.roots.len(), 2);
|
||||
assert_eq!(count_only.visual_count, 24);
|
||||
assert_eq!(prototypes.len(), 24);
|
||||
assert_eq!(plan.visual_count, 11);
|
||||
assert_eq!(plan.model_count, 11);
|
||||
assert_eq!(plan.material_count, 62);
|
||||
assert_eq!(plan.texture_count, 77);
|
||||
}
|
||||
|
||||
fn fixture_root(part: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("testdata")
|
||||
.join(part)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,308 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Parser limits shared by binary formats.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// Maximum file bytes.
|
||||
pub max_file_bytes: u64,
|
||||
/// Maximum entries.
|
||||
pub max_entries: u32,
|
||||
/// Maximum string bytes.
|
||||
pub max_string_bytes: u32,
|
||||
/// Maximum array items.
|
||||
pub max_array_items: u32,
|
||||
/// Maximum recursion depth.
|
||||
pub max_recursion_depth: u16,
|
||||
/// Maximum decoded bytes.
|
||||
pub max_decoded_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_string_bytes: 64 * 1024,
|
||||
max_array_items: 1_000_000,
|
||||
max_recursion_depth: 64,
|
||||
max_decoded_bytes: 512 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DecodeError {
|
||||
/// Input ended before requested bytes.
|
||||
UnexpectedEof {
|
||||
/// Offset where read was attempted.
|
||||
offset: u64,
|
||||
/// Required byte count.
|
||||
needed: u64,
|
||||
/// Remaining byte count.
|
||||
remaining: u64,
|
||||
},
|
||||
/// Arithmetic overflow.
|
||||
IntegerOverflow,
|
||||
/// Count exceeds limit.
|
||||
LimitExceeded {
|
||||
/// Declared count.
|
||||
count: u64,
|
||||
/// Configured limit.
|
||||
limit: u64,
|
||||
},
|
||||
/// Cursor did not end at EOF.
|
||||
TrailingBytes {
|
||||
/// Offset where EOF was expected.
|
||||
offset: u64,
|
||||
/// Remaining byte count.
|
||||
remaining: u64,
|
||||
},
|
||||
/// Invalid data.
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnexpectedEof {
|
||||
offset,
|
||||
needed,
|
||||
remaining,
|
||||
} => write!(
|
||||
f,
|
||||
"unexpected EOF at {offset}: need {needed}, have {remaining}"
|
||||
),
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Self::LimitExceeded { count, limit } => {
|
||||
write!(f, "count {count} exceeds limit {limit}")
|
||||
}
|
||||
Self::TrailingBytes { offset, remaining } => {
|
||||
write!(f, "trailing bytes at {offset}: {remaining}")
|
||||
}
|
||||
Self::Invalid(reason) => write!(f, "invalid data: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// Cursor checkpoint.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Checkpoint(pub u64);
|
||||
|
||||
/// Bounded cursor.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
/// Creates a cursor.
|
||||
#[must_use]
|
||||
pub fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, offset: 0 }
|
||||
}
|
||||
|
||||
/// Current offset.
|
||||
#[must_use]
|
||||
pub fn offset(&self) -> u64 {
|
||||
self.offset as u64
|
||||
}
|
||||
|
||||
/// Remaining bytes.
|
||||
#[must_use]
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.bytes.len().saturating_sub(self.offset)
|
||||
}
|
||||
|
||||
/// Creates a checkpoint.
|
||||
#[must_use]
|
||||
pub fn checkpoint(&self) -> Checkpoint {
|
||||
Checkpoint(self.offset())
|
||||
}
|
||||
|
||||
/// Reads exact bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::IntegerOverflow`] if the requested end offset
|
||||
/// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough
|
||||
/// bytes remaining.
|
||||
pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
if end > self.bytes.len() {
|
||||
return Err(DecodeError::UnexpectedEof {
|
||||
offset: self.offset(),
|
||||
needed: len as u64,
|
||||
remaining: self.remaining() as u64,
|
||||
});
|
||||
}
|
||||
let out = &self.bytes[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a little-endian u16.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if two bytes cannot be read.
|
||||
pub fn read_u16_le(&mut self) -> Result<u16, DecodeError> {
|
||||
let b = self.read_exact(2)?;
|
||||
Ok(u16::from_le_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian u32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_u32_le(&mut self) -> Result<u32, DecodeError> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian i32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_i32_le(&mut self) -> Result<i32, DecodeError> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian f32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_f32_le(&mut self) -> Result<f32, DecodeError> {
|
||||
Ok(f32::from_bits(self.read_u32_le()?))
|
||||
}
|
||||
|
||||
/// Requires exact EOF.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::TrailingBytes`] when unread bytes remain.
|
||||
pub fn require_eof(&self) -> Result<(), DecodeError> {
|
||||
if self.remaining() == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DecodeError::TrailingBytes {
|
||||
offset: self.offset(),
|
||||
remaining: self.remaining() as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `count * stride <= remaining` and returns bytes as usize.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion
|
||||
/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is
|
||||
/// larger than the remaining bounded input.
|
||||
pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result<usize, DecodeError> {
|
||||
let bytes = count
|
||||
.checked_mul(stride)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
if bytes > remaining {
|
||||
return Err(DecodeError::UnexpectedEof {
|
||||
offset: 0,
|
||||
needed: bytes,
|
||||
remaining,
|
||||
});
|
||||
}
|
||||
usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow)
|
||||
}
|
||||
|
||||
/// Validates a declared allocation size before constructing the allocation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than
|
||||
/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot
|
||||
/// be represented by the host `usize`.
|
||||
pub fn checked_allocation_len(declared: u64, limit: u64) -> Result<usize, DecodeError> {
|
||||
if declared > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: declared,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow)
|
||||
}
|
||||
|
||||
/// Reads length-prefixed bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the
|
||||
/// declared payload is truncated.
|
||||
pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, DecodeError> {
|
||||
let len = cursor.read_u32_le()?;
|
||||
if len > max {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: u64::from(len),
|
||||
limit: u64::from(max),
|
||||
});
|
||||
}
|
||||
let len = checked_allocation_len(u64::from(len), u64::from(max))?;
|
||||
Ok(cursor.read_exact(len)?.to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_count_stride_overflow() {
|
||||
assert_eq!(
|
||||
checked_count_bytes(u64::MAX, 2, u64::MAX),
|
||||
Err(DecodeError::IntegerOverflow)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_eof_reports_trailing() {
|
||||
let mut cursor = Cursor::new(&[1, 2]);
|
||||
assert_eq!(cursor.read_exact(1).expect("byte"), &[1]);
|
||||
assert!(matches!(
|
||||
cursor.require_eof(),
|
||||
Err(DecodeError::TrailingBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_declared_allocation_before_read() {
|
||||
assert_eq!(
|
||||
checked_allocation_len(1025, 1024),
|
||||
Err(DecodeError::LimitExceeded {
|
||||
count: 1025,
|
||||
limit: 1024
|
||||
})
|
||||
);
|
||||
|
||||
let bytes = 2048u32.to_le_bytes();
|
||||
let mut cursor = Cursor::new(&bytes);
|
||||
assert_eq!(
|
||||
read_lp_bytes(&mut cursor, 1024),
|
||||
Err(DecodeError::LimitExceeded {
|
||||
count: 2048,
|
||||
limit: 1024
|
||||
})
|
||||
);
|
||||
assert_eq!(cursor.offset(), 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-corpus"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,695 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Licensed corpus discovery and aggregate reports.
|
||||
|
||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Corpus kind.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CorpusKind {
|
||||
/// Demo corpus.
|
||||
Demo,
|
||||
/// Part 1 full game.
|
||||
Part1,
|
||||
/// Part 2 full game.
|
||||
Part2,
|
||||
/// Unknown local directory.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Corpus root.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CorpusRoot(pub PathBuf);
|
||||
|
||||
/// Discovery options.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct DiscoverOptions {
|
||||
/// Whether symlinks may be traversed.
|
||||
pub follow_symlinks: bool,
|
||||
}
|
||||
|
||||
/// File manifest entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManifestEntry {
|
||||
/// Normalized relative path.
|
||||
pub path: String,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Stable content fingerprint.
|
||||
pub hash: u64,
|
||||
}
|
||||
|
||||
/// Corpus manifest.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CorpusManifest {
|
||||
/// Kind.
|
||||
pub kind: CorpusKind,
|
||||
/// Sorted files.
|
||||
pub files: Vec<ManifestEntry>,
|
||||
/// Casefold collisions.
|
||||
pub casefold_collisions: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Aggregate report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CorpusReport {
|
||||
/// Schema version.
|
||||
pub schema: u32,
|
||||
/// Kind.
|
||||
pub kind: CorpusKind,
|
||||
/// Total files.
|
||||
pub files: usize,
|
||||
/// Total bytes.
|
||||
pub bytes: u64,
|
||||
/// Metrics.
|
||||
pub metrics: BTreeMap<String, u64>,
|
||||
/// Casefold collision count.
|
||||
pub casefold_collisions: usize,
|
||||
/// Manifest fingerprint.
|
||||
pub fingerprint: u64,
|
||||
}
|
||||
|
||||
/// Corpus error.
|
||||
#[derive(Debug)]
|
||||
pub enum CorpusError {
|
||||
/// I/O failure.
|
||||
Io {
|
||||
/// Path where I/O failed.
|
||||
path: PathBuf,
|
||||
/// Source error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// Invalid root.
|
||||
InvalidRoot(PathBuf),
|
||||
/// Invalid path.
|
||||
InvalidPath(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for CorpusError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
|
||||
Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
|
||||
Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CorpusError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io { source, .. } => Some(source),
|
||||
Self::InvalidRoot(_) | Self::InvalidPath(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovers a corpus under a root directory.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CorpusError`] if the root is invalid, traversal encounters an I/O
|
||||
/// error, or a discovered path cannot be represented by the legacy path policy.
|
||||
pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest, CorpusError> {
|
||||
if !root.is_dir() {
|
||||
return Err(CorpusError::InvalidRoot(root.to_path_buf()));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, options, &mut files)?;
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
let kind = classify(root, &files);
|
||||
let casefold_collisions = detect_casefold_collisions(&files);
|
||||
Ok(CorpusManifest {
|
||||
kind,
|
||||
files,
|
||||
casefold_collisions,
|
||||
})
|
||||
}
|
||||
|
||||
fn walk(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
options: DiscoverOptions,
|
||||
out: &mut Vec<ManifestEntry>,
|
||||
) -> Result<(), CorpusError> {
|
||||
let read_dir = fs::read_dir(dir).map_err(|source| CorpusError::Io {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mut entries = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|source| CorpusError::Io {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
entries.push(entry.path());
|
||||
}
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&path).map_err(|source| CorpusError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() && !options.follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
walk(root, &path, options, out)?;
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let rel_text = rel
|
||||
.to_str()
|
||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible)
|
||||
.map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?;
|
||||
let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
out.push(ManifestEntry {
|
||||
path: normalized.as_str().to_string(),
|
||||
size: metadata.len(),
|
||||
hash: stable_hash(&bytes),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn classify(root: &Path, files: &[ManifestEntry]) -> CorpusKind {
|
||||
let name = root
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if name == "IS" {
|
||||
CorpusKind::Part1
|
||||
} else if name == "IS2" {
|
||||
CorpusKind::Part2
|
||||
} else if files
|
||||
.iter()
|
||||
.any(|f| f.path.eq_ignore_ascii_case("iron_3d.exe"))
|
||||
{
|
||||
CorpusKind::Part1
|
||||
} else {
|
||||
CorpusKind::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
|
||||
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
|
||||
for file in files {
|
||||
grouped
|
||||
.entry(ascii_lookup_key(file.path.as_bytes()).0)
|
||||
.or_default()
|
||||
.insert(file.path.clone());
|
||||
}
|
||||
grouped
|
||||
.into_values()
|
||||
.filter(|paths| paths.len() > 1)
|
||||
.map(|paths| paths.into_iter().collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds aggregate report.
|
||||
#[must_use]
|
||||
pub fn report(root: &Path, manifest: &CorpusManifest) -> CorpusReport {
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("nres_files".to_string(), 0);
|
||||
metrics.insert("nres_entries".to_string(), 0);
|
||||
metrics.insert("rsli_files".to_string(), 0);
|
||||
metrics.insert("tma_files".to_string(), 0);
|
||||
metrics.insert("land_msh_files".to_string(), 0);
|
||||
metrics.insert("land_map_files".to_string(), 0);
|
||||
metrics.insert("unit_dat_files".to_string(), 0);
|
||||
metrics.insert("msh_entries".to_string(), 0);
|
||||
metrics.insert("mat0_entries".to_string(), 0);
|
||||
metrics.insert("texm_entries".to_string(), 0);
|
||||
metrics.insert("fxid_entries".to_string(), 0);
|
||||
metrics.insert("wear_entries".to_string(), 0);
|
||||
|
||||
for entry in &manifest.files {
|
||||
let lower = entry.path.to_ascii_lowercase();
|
||||
if lower.ends_with("data.tma") {
|
||||
bump(&mut metrics, "tma_files", 1);
|
||||
}
|
||||
if lower.ends_with("land.msh") {
|
||||
bump(&mut metrics, "land_msh_files", 1);
|
||||
}
|
||||
if lower.ends_with("land.map") {
|
||||
bump(&mut metrics, "land_map_files", 1);
|
||||
}
|
||||
if has_extension(&lower, "dat")
|
||||
&& (lower.starts_with("units/") || lower.contains("/units/"))
|
||||
{
|
||||
bump(&mut metrics, "unit_dat_files", 1);
|
||||
}
|
||||
|
||||
let path = root.join(&entry.path);
|
||||
if let Ok(bytes) = fs::read(path) {
|
||||
if bytes.starts_with(b"NRes") {
|
||||
bump(&mut metrics, "nres_files", 1);
|
||||
if let Some(entries) = inspect_nres_entries(&bytes) {
|
||||
bump(&mut metrics, "nres_entries", entries.len() as u64);
|
||||
for entry in entries {
|
||||
let name = entry.name.to_ascii_lowercase();
|
||||
if has_extension(&name, "msh") {
|
||||
bump(&mut metrics, "msh_entries", 1);
|
||||
}
|
||||
match entry.kind {
|
||||
0x3054_414D => {
|
||||
bump(&mut metrics, "mat0_entries", 1);
|
||||
}
|
||||
0x6D78_6554 => {
|
||||
bump(&mut metrics, "texm_entries", 1);
|
||||
}
|
||||
0x4449_5846 => {
|
||||
bump(&mut metrics, "fxid_entries", 1);
|
||||
}
|
||||
0x5241_4557 => {
|
||||
bump(&mut metrics, "wear_entries", 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if bytes.starts_with(b"NL") {
|
||||
bump(&mut metrics, "rsli_files", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CorpusReport {
|
||||
schema: 1,
|
||||
kind: manifest.kind,
|
||||
files: manifest.files.len(),
|
||||
bytes: manifest.files.iter().map(|f| f.size).sum(),
|
||||
metrics,
|
||||
casefold_collisions: manifest.casefold_collisions.len(),
|
||||
fingerprint: fingerprint(manifest),
|
||||
}
|
||||
}
|
||||
|
||||
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
||||
if let Some(value) = metrics.get_mut(key) {
|
||||
*value = value.saturating_add(delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_extension(path: &str, expected: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct NresEntryBrief {
|
||||
kind: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn inspect_nres_entries(bytes: &[u8]) -> Option<Vec<NresEntryBrief>> {
|
||||
if bytes.len() < 16 || !bytes.starts_with(b"NRes") {
|
||||
return None;
|
||||
}
|
||||
let count = i32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?);
|
||||
if count < 0 {
|
||||
return None;
|
||||
}
|
||||
let count = usize::try_from(count).ok()?;
|
||||
let directory_len = count.checked_mul(64)?;
|
||||
let directory_offset = bytes.len().checked_sub(directory_len)?;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
for index in 0..count {
|
||||
let base = directory_offset.checked_add(index.checked_mul(64)?)?;
|
||||
let kind = u32::from_le_bytes(bytes.get(base..base + 4)?.try_into().ok()?);
|
||||
let raw = bytes.get(base + 20..base + 56)?;
|
||||
let len = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
|
||||
names.push(NresEntryBrief {
|
||||
kind,
|
||||
name: String::from_utf8_lossy(&raw[..len]).to_string(),
|
||||
});
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Computes stable manifest fingerprint.
|
||||
#[must_use]
|
||||
pub fn fingerprint(manifest: &CorpusManifest) -> u64 {
|
||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
||||
for file in &manifest.files {
|
||||
hash_into(&mut state, file.path.as_bytes());
|
||||
hash_into(&mut state, &file.size.to_le_bytes());
|
||||
hash_into(&mut state, &file.hash.to_le_bytes());
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
||||
hash_into(&mut state, bytes);
|
||||
state
|
||||
}
|
||||
|
||||
fn hash_into(state: &mut u64, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
*state ^= u64::from(*byte);
|
||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes report atomically.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CorpusError`] if the parent directory, temporary file, write, or
|
||||
/// final rename operation fails.
|
||||
pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), CorpusError> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|source| CorpusError::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let mut file = fs::File::create(&tmp).map_err(|source| CorpusError::Io {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
file.write_all(render_report_json(report).as_bytes())
|
||||
.map_err(|source| CorpusError::Io {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
file.sync_all().map_err(|source| CorpusError::Io {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
fs::rename(&tmp, path).map_err(|source| CorpusError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders report JSON.
|
||||
#[must_use]
|
||||
pub fn render_report_json(report: &CorpusReport) -> String {
|
||||
let mut out = format!(
|
||||
"{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{:016x}\",\"metrics\":{{",
|
||||
report.schema,
|
||||
report.kind,
|
||||
report.files,
|
||||
report.bytes,
|
||||
report.casefold_collisions,
|
||||
report.fingerprint
|
||||
);
|
||||
for (idx, (key, value)) in report.metrics.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('"');
|
||||
out.push_str(key);
|
||||
out.push_str("\":");
|
||||
out.push_str(&value.to_string());
|
||||
}
|
||||
out.push_str("}}");
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_path::join_under;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn report_for_testdata_roots() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("testdata")
|
||||
.join("IS");
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
||||
let report = report(&root, &manifest);
|
||||
assert!(report.files > 0);
|
||||
assert!(report.metrics["nres_files"] > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part1_manifest_profile_and_counts_match_baseline() {
|
||||
let root = testdata_root("IS");
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
||||
let report = report(&root, &manifest);
|
||||
|
||||
assert_eq!(manifest.kind, CorpusKind::Part1);
|
||||
assert_eq!(report.files, 1_017);
|
||||
assert_eq!(report.metrics["nres_files"], 120);
|
||||
assert_eq!(report.metrics["rsli_files"], 2);
|
||||
assert_eq!(report.metrics["tma_files"], 29);
|
||||
assert_eq!(report.metrics["land_msh_files"], 33);
|
||||
assert_eq!(report.metrics["land_map_files"], 33);
|
||||
assert_eq!(report.metrics["unit_dat_files"], 425);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part2_manifest_profile_and_counts_match_baseline() {
|
||||
let root = testdata_root("IS2");
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
||||
let report = report(&root, &manifest);
|
||||
|
||||
assert_eq!(manifest.kind, CorpusKind::Part2);
|
||||
assert_eq!(report.files, 1_302);
|
||||
assert_eq!(report.metrics["nres_files"], 134);
|
||||
assert_eq!(report.metrics["rsli_files"], 2);
|
||||
assert_eq!(report.metrics["tma_files"], 31);
|
||||
assert_eq!(report.metrics["land_msh_files"], 32);
|
||||
assert_eq!(report.metrics["land_map_files"], 32);
|
||||
assert_eq!(report.metrics["unit_dat_files"], 676);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part1_has_no_casefold_relative_path_collisions() {
|
||||
let root = testdata_root("IS");
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
|
||||
|
||||
assert!(manifest.casefold_collisions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part2_has_no_casefold_relative_path_collisions() {
|
||||
let root = testdata_root("IS2");
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
|
||||
|
||||
assert!(manifest.casefold_collisions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part1_paths_stay_under_root() {
|
||||
assert_discovered_paths_stay_under_root("IS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_part2_paths_stay_under_root() {
|
||||
assert_discovered_paths_stay_under_root("IS2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Part1,
|
||||
files: vec![ManifestEntry {
|
||||
path: "secret/payload.bin".to_string(),
|
||||
size: 4,
|
||||
hash: stable_hash(b"DATA"),
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let report = report(Path::new("."), &manifest);
|
||||
let json = render_report_json(&report);
|
||||
|
||||
assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
|
||||
assert!(json.contains("\"fingerprint\":"));
|
||||
assert!(json.contains("\"metrics\":"));
|
||||
assert!(!json.contains("secret/payload.bin"));
|
||||
assert!(!json.contains("DATA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_traversal_is_creation_order_independent() {
|
||||
let first = temp_dir("order-first");
|
||||
let second = temp_dir("order-second");
|
||||
fs::create_dir_all(first.join("nested")).expect("first nested");
|
||||
fs::create_dir_all(second.join("nested")).expect("second nested");
|
||||
|
||||
fs::write(first.join("b.bin"), b"b").expect("first b");
|
||||
fs::write(first.join("nested").join("a.bin"), b"a").expect("first a");
|
||||
fs::write(second.join("nested").join("a.bin"), b"a").expect("second a");
|
||||
fs::write(second.join("b.bin"), b"b").expect("second b");
|
||||
|
||||
let first_manifest = discover(&first, DiscoverOptions::default()).expect("first manifest");
|
||||
let second_manifest =
|
||||
discover(&second, DiscoverOptions::default()).expect("second manifest");
|
||||
|
||||
assert_eq!(first_manifest.files, second_manifest.files);
|
||||
let _ = fs::remove_dir_all(first);
|
||||
let _ = fs::remove_dir_all(second);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_directory_produces_error() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = temp_dir("unreadable");
|
||||
let child = root.join("locked");
|
||||
fs::create_dir_all(&child).expect("locked dir");
|
||||
fs::set_permissions(&child, fs::Permissions::from_mode(0o000)).expect("lock dir");
|
||||
|
||||
let result = discover(&root, DiscoverOptions::default());
|
||||
|
||||
fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).expect("unlock dir");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
assert!(matches!(result, Err(CorpusError::Io { path, .. }) if path.ends_with("locked")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_loop_is_not_traversed_by_default() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = temp_dir("symlink-loop");
|
||||
fs::write(root.join("real.bin"), b"real").expect("real file");
|
||||
symlink(&root, root.join("loop")).expect("loop symlink");
|
||||
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
||||
|
||||
assert_eq!(manifest.files.len(), 1);
|
||||
assert_eq!(manifest.files[0].path, "real.bin");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casefold_collisions_are_registered() {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![
|
||||
ManifestEntry {
|
||||
path: "Textures/Foo.TEX".to_string(),
|
||||
size: 1,
|
||||
hash: 1,
|
||||
},
|
||||
ManifestEntry {
|
||||
path: "textures/foo.tex".to_string(),
|
||||
size: 1,
|
||||
hash: 2,
|
||||
},
|
||||
],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let collisions = detect_casefold_collisions(&manifest.files);
|
||||
|
||||
assert_eq!(
|
||||
collisions,
|
||||
vec![vec![
|
||||
"Textures/Foo.TEX".to_string(),
|
||||
"textures/foo.tex".to_string()
|
||||
]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_changes() {
|
||||
let mut manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "a".to_string(),
|
||||
size: 1,
|
||||
hash: 1,
|
||||
}],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let a = fingerprint(&manifest);
|
||||
manifest.files[0].hash = 2;
|
||||
assert_ne!(a, fingerprint(&manifest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_report_write() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"fparkan-report-{}.json",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
));
|
||||
let report = CorpusReport {
|
||||
schema: 1,
|
||||
kind: CorpusKind::Unknown,
|
||||
files: 0,
|
||||
bytes: 0,
|
||||
metrics: BTreeMap::new(),
|
||||
casefold_collisions: 0,
|
||||
fingerprint: 0,
|
||||
};
|
||||
write_report_atomic(&tmp, &report).expect("write");
|
||||
assert!(tmp.is_file());
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"fparkan-corpus-{name}-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
fn testdata_root(part: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("testdata")
|
||||
.join(part)
|
||||
}
|
||||
|
||||
fn assert_discovered_paths_stay_under_root(part: &str) {
|
||||
let root = testdata_root(part);
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("licensed manifest");
|
||||
|
||||
for entry in &manifest.files {
|
||||
let normalized = normalize_relative(entry.path.as_bytes(), PathPolicy::HostCompatible)
|
||||
.expect("discovered path should re-normalize");
|
||||
let joined = join_under(&root, &normalized).expect("discovered path should join");
|
||||
assert!(
|
||||
joined.starts_with(&root),
|
||||
"discovered path escaped root: {}",
|
||||
entry.path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-diagnostics"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,301 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Structured diagnostics shared by `FParkan` crates.
|
||||
|
||||
/// Diagnostic severity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Severity {
|
||||
/// Informational note.
|
||||
Info,
|
||||
/// Recoverable warning.
|
||||
Warning,
|
||||
/// Error for the current operation.
|
||||
Error,
|
||||
/// Fatal error for the current run.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
/// Evidence level for a contract or interpretation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EvidenceStatus {
|
||||
/// Described by project documentation.
|
||||
Documented,
|
||||
/// Verified by synthetic fixtures.
|
||||
SyntheticVerified,
|
||||
/// Verified against the licensed corpus.
|
||||
CorpusVerified,
|
||||
/// Verified by runtime capture.
|
||||
RuntimeCaptured,
|
||||
/// Working hypothesis; not a runtime contract.
|
||||
Hypothesis,
|
||||
}
|
||||
|
||||
/// Operation phase where a diagnostic was produced.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Phase {
|
||||
/// Discovery.
|
||||
Discover,
|
||||
/// Read.
|
||||
Read,
|
||||
/// Parse.
|
||||
Parse,
|
||||
/// Validate.
|
||||
Validate,
|
||||
/// Resolve.
|
||||
Resolve,
|
||||
/// Prepare.
|
||||
Prepare,
|
||||
/// Construct.
|
||||
Construct,
|
||||
/// Register.
|
||||
Register,
|
||||
/// Simulate.
|
||||
Simulate,
|
||||
/// Render.
|
||||
Render,
|
||||
}
|
||||
|
||||
/// Byte span in an input source.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SourceSpan {
|
||||
/// Start offset.
|
||||
pub offset: u64,
|
||||
/// Length in bytes.
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Stable diagnostic code.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct DiagnosticCode(pub &'static str);
|
||||
|
||||
/// Context attached to a diagnostic.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct DiagnosticContext {
|
||||
/// Phase.
|
||||
pub phase: Option<Phase>,
|
||||
/// Redacted or logical path.
|
||||
pub path: Option<String>,
|
||||
/// Archive entry name.
|
||||
pub archive_entry: Option<String>,
|
||||
/// Object/prototype key.
|
||||
pub object_key: Option<String>,
|
||||
/// Input span.
|
||||
pub span: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
/// Structured diagnostic with cause chain.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
/// Stable code.
|
||||
pub code: DiagnosticCode,
|
||||
/// Severity.
|
||||
pub severity: Severity,
|
||||
/// Human message.
|
||||
pub message: String,
|
||||
/// Context.
|
||||
pub context: DiagnosticContext,
|
||||
/// Causes.
|
||||
pub causes: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Creates a diagnostic with default error severity.
|
||||
#[must_use]
|
||||
pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
|
||||
Diagnostic {
|
||||
code,
|
||||
severity: Severity::Error,
|
||||
message: message.into(),
|
||||
context: DiagnosticContext::default(),
|
||||
causes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
/// Returns a copy with severity changed.
|
||||
#[must_use]
|
||||
pub fn with_severity(mut self, severity: Severity) -> Self {
|
||||
self.severity = severity;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a copy with context changed.
|
||||
#[must_use]
|
||||
pub fn with_context(mut self, context: DiagnosticContext) -> Self {
|
||||
self.context = context;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a cause.
|
||||
pub fn push_cause(&mut self, cause: Diagnostic) {
|
||||
self.causes.push(cause);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a compact human-readable diagnostic.
|
||||
#[must_use]
|
||||
pub fn render_human(diagnostic: &Diagnostic) -> String {
|
||||
let mut out = format!(
|
||||
"{:?} {}: {}",
|
||||
diagnostic.severity, diagnostic.code.0, diagnostic.message
|
||||
);
|
||||
if let Some(path) = &diagnostic.context.path {
|
||||
out.push_str(" [");
|
||||
out.push_str(path);
|
||||
out.push(']');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders deterministic JSON without requiring a serialization dependency.
|
||||
#[must_use]
|
||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||
fn esc(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
out.push('{');
|
||||
out.push_str("\"code\":\"");
|
||||
out.push_str(&esc(diagnostic.code.0));
|
||||
out.push_str("\",\"severity\":\"");
|
||||
out.push_str(match diagnostic.severity {
|
||||
Severity::Info => "info",
|
||||
Severity::Warning => "warning",
|
||||
Severity::Error => "error",
|
||||
Severity::Fatal => "fatal",
|
||||
});
|
||||
out.push_str("\",\"message\":\"");
|
||||
out.push_str(&esc(&diagnostic.message));
|
||||
out.push_str("\",\"context\":{");
|
||||
if let Some(phase) = diagnostic.context.phase {
|
||||
out.push_str("\"phase\":\"");
|
||||
out.push_str(match phase {
|
||||
Phase::Discover => "discover",
|
||||
Phase::Read => "read",
|
||||
Phase::Parse => "parse",
|
||||
Phase::Validate => "validate",
|
||||
Phase::Resolve => "resolve",
|
||||
Phase::Prepare => "prepare",
|
||||
Phase::Construct => "construct",
|
||||
Phase::Register => "register",
|
||||
Phase::Simulate => "simulate",
|
||||
Phase::Render => "render",
|
||||
});
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(path) = &diagnostic.context.path {
|
||||
if diagnostic.context.phase.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"path\":\"");
|
||||
out.push_str(&esc(path));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(entry) = &diagnostic.context.archive_entry {
|
||||
if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"archive_entry\":\"");
|
||||
out.push_str(&esc(entry));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(key) = &diagnostic.context.object_key {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"object_key\":\"");
|
||||
out.push_str(&esc(key));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(span) = diagnostic.context.span {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
|| diagnostic.context.object_key.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"span\":{\"offset\":");
|
||||
out.push_str(&span.offset.to_string());
|
||||
out.push_str(",\"length\":");
|
||||
out.push_str(&span.length.to_string());
|
||||
out.push('}');
|
||||
}
|
||||
out.push_str("},\"causes\":[");
|
||||
for (idx, cause) in diagnostic.causes.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&render_json(cause));
|
||||
}
|
||||
out.push_str("]}");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_is_stable() {
|
||||
let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
render_json(&d),
|
||||
"{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_chain_preserves_context() {
|
||||
let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
|
||||
phase: Some(Phase::Resolve),
|
||||
path: Some("archives/material.lib".to_string()),
|
||||
archive_entry: Some("MATERIAL.MAT0".to_string()),
|
||||
object_key: Some("unit/tank".to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 12,
|
||||
length: 4,
|
||||
}),
|
||||
});
|
||||
root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
path: Some("archives/material.lib".to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 16,
|
||||
length: 8,
|
||||
}),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
));
|
||||
|
||||
let json = render_json(&root);
|
||||
|
||||
assert!(json.contains("\"code\":\"ROOT\""));
|
||||
assert!(json.contains("\"phase\":\"resolve\""));
|
||||
assert!(json.contains("\"path\":\"archives/material.lib\""));
|
||||
assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
|
||||
assert!(json.contains("\"object_key\":\"unit/tank\""));
|
||||
assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
|
||||
assert!(json.contains("\"code\":\"CAUSE\""));
|
||||
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-fx"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fparkan-material"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-mission-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "fparkan-msh"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-animation = { path = "../fparkan-animation" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-nres"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-path"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,259 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Legacy path normalization and ASCII lookup semantics.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Original bytes.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct OriginalPathBytes(pub Vec<u8>);
|
||||
|
||||
impl OriginalPathBytes {
|
||||
/// Returns the preserved byte image.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns the preserved byte image as an owned vector.
|
||||
#[must_use]
|
||||
pub fn into_vec(self) -> Vec<u8> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized relative path.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct NormalizedPath(String);
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns string view.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized path paired with its original byte image.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NormalizedPathWithOriginal {
|
||||
normalized: NormalizedPath,
|
||||
original: OriginalPathBytes,
|
||||
}
|
||||
|
||||
impl NormalizedPathWithOriginal {
|
||||
/// Returns normalized path.
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> &NormalizedPath {
|
||||
&self.normalized
|
||||
}
|
||||
|
||||
/// Returns original path bytes.
|
||||
#[must_use]
|
||||
pub fn original(&self) -> &OriginalPathBytes {
|
||||
&self.original
|
||||
}
|
||||
|
||||
/// Splits into normalized and original path parts.
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) {
|
||||
(self.normalized, self.original)
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII lookup key.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct LookupKey(pub Vec<u8>);
|
||||
|
||||
/// Resource name bytes.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResourceName(pub Vec<u8>);
|
||||
|
||||
/// Path policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PathPolicy {
|
||||
/// Strict legacy relative resource path.
|
||||
StrictLegacy,
|
||||
/// Host compatible relative path.
|
||||
HostCompatible,
|
||||
}
|
||||
|
||||
/// Path error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PathError {
|
||||
/// Empty path.
|
||||
Empty,
|
||||
/// Embedded NUL.
|
||||
EmbeddedNul,
|
||||
/// Absolute path.
|
||||
Absolute,
|
||||
/// Parent traversal.
|
||||
ParentTraversal,
|
||||
/// Host path escape.
|
||||
EscapesRoot,
|
||||
/// Invalid UTF-8 after normalization.
|
||||
InvalidUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PathError {}
|
||||
|
||||
/// Normalizes a relative path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
||||
/// legacy separator normalization.
|
||||
pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||
if raw.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
if raw.contains(&0) {
|
||||
return Err(PathError::EmbeddedNul);
|
||||
}
|
||||
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
|
||||
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for part in text.split(['/', '\\']) {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
Ok(NormalizedPath(parts.join("/")))
|
||||
}
|
||||
|
||||
/// Normalizes a relative path while preserving its original bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] under the same conditions as [`normalize_relative`].
|
||||
pub fn normalize_relative_with_original(
|
||||
raw: &[u8],
|
||||
policy: PathPolicy,
|
||||
) -> Result<NormalizedPathWithOriginal, PathError> {
|
||||
let normalized = normalize_relative(raw, policy)?;
|
||||
Ok(NormalizedPathWithOriginal {
|
||||
normalized,
|
||||
original: OriginalPathBytes(raw.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
fn has_drive_prefix(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
/// Builds an ASCII-only casefold lookup key.
|
||||
#[must_use]
|
||||
pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
||||
LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect())
|
||||
}
|
||||
|
||||
/// Ensures relative path does not escape.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||
/// to address a parent directory.
|
||||
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
if rel.0.split('/').any(|part| part == "..") {
|
||||
Err(PathError::ParentTraversal)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins normalized path under root.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] if the normalized path fails the escape check.
|
||||
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
||||
reject_escape(rel)?;
|
||||
Ok(root.join(rel.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_separators() {
|
||||
let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy)
|
||||
.expect("path");
|
||||
assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_escape() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_drive_and_nul_paths() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::EmbeddedNul)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_under_keeps_normalized_path_below_root() {
|
||||
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
||||
.expect("relative path");
|
||||
let joined = join_under(Path::new("/game"), &rel).expect("join");
|
||||
|
||||
assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_casefold_does_not_unicode_fold() {
|
||||
assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_original_bytes_remain_stable() {
|
||||
let raw = "DATA/Тест.bin".as_bytes();
|
||||
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy)
|
||||
.expect("path with non-ASCII UTF-8");
|
||||
|
||||
assert_eq!(path.normalized().as_str().as_bytes(), raw);
|
||||
assert_eq!(path.original().as_bytes(), raw);
|
||||
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_separators_and_raw_bytes_are_preserved() {
|
||||
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
||||
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
|
||||
|
||||
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
||||
assert_eq!(path.original().as_bytes(), raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-platform"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,93 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Platform ports for clocks, input, events, windows, and graphics requests.
|
||||
|
||||
/// Monotonic instant.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct MonotonicInstant(pub u64);
|
||||
|
||||
/// Monotonic clock.
|
||||
pub trait MonotonicClock {
|
||||
/// Current instant.
|
||||
fn now(&self) -> MonotonicInstant;
|
||||
}
|
||||
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Quit requested.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Platform error.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformError {
|
||||
/// Backend failed.
|
||||
Backend,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlatformError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformError {}
|
||||
|
||||
/// Event source.
|
||||
pub trait EventSource {
|
||||
/// Polls events.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the backend cannot collect events.
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
/// Physical size.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PhysicalSize {
|
||||
/// Width.
|
||||
pub width: u32,
|
||||
/// Height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Window port.
|
||||
pub trait WindowPort {
|
||||
/// Drawable size.
|
||||
fn drawable_size(&self) -> PhysicalSize;
|
||||
/// Presents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the backend cannot present the current
|
||||
/// frame.
|
||||
fn present(&mut self) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
/// Graphics profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GraphicsProfile {
|
||||
/// Desktop core.
|
||||
DesktopCore,
|
||||
/// Embedded profile.
|
||||
Embedded,
|
||||
}
|
||||
|
||||
/// Version.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Version {
|
||||
/// Major.
|
||||
pub major: u8,
|
||||
/// Minor.
|
||||
pub minor: u8,
|
||||
}
|
||||
|
||||
/// Graphics context request.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GraphicsContextRequest {
|
||||
/// Profile.
|
||||
pub profile: GraphicsProfile,
|
||||
/// Version.
|
||||
pub version: Version,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "fparkan-prototype"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,554 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Backend-neutral render commands and deterministic captures.
|
||||
|
||||
use fparkan_world::OriginalObjectId;
|
||||
|
||||
/// Immutable camera data visible to command generation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CameraSnapshot {
|
||||
/// View matrix, row-major.
|
||||
pub view: [f32; 16],
|
||||
/// Projection matrix, row-major.
|
||||
pub projection: [f32; 16],
|
||||
}
|
||||
|
||||
impl Default for CameraSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
view: identity_transform(),
|
||||
projection: identity_transform(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw id.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct DrawId(pub u64);
|
||||
|
||||
/// GPU mesh id.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GpuMeshId(pub u64);
|
||||
|
||||
/// GPU material id.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GpuMaterialId(pub u64);
|
||||
|
||||
/// Render phase.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum RenderPhase {
|
||||
/// Terrain.
|
||||
Terrain,
|
||||
/// Opaque.
|
||||
Opaque,
|
||||
/// Alpha test.
|
||||
AlphaTest,
|
||||
/// Transparent.
|
||||
Transparent,
|
||||
/// Effects.
|
||||
Effects,
|
||||
/// Debug.
|
||||
Debug,
|
||||
/// UI.
|
||||
Ui,
|
||||
}
|
||||
|
||||
/// Index range.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct IndexRange {
|
||||
/// Start.
|
||||
pub start: u32,
|
||||
/// Count.
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// A draw candidate in an immutable render snapshot.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RenderSnapshotDraw {
|
||||
/// Draw id.
|
||||
pub id: DrawId,
|
||||
/// Phase.
|
||||
pub phase: RenderPhase,
|
||||
/// Object id.
|
||||
pub object_id: Option<OriginalObjectId>,
|
||||
/// Mesh.
|
||||
pub mesh: GpuMeshId,
|
||||
/// Material table after WEAR/MAT0 fallback resolution.
|
||||
pub material_slots: Vec<GpuMaterialId>,
|
||||
/// Batch material index into [`Self::material_slots`].
|
||||
pub material_index: u16,
|
||||
/// Node transform matrix, row-major.
|
||||
pub transform: [f32; 16],
|
||||
/// Index range.
|
||||
pub range: IndexRange,
|
||||
/// Stable sort order.
|
||||
pub stable_order: u64,
|
||||
}
|
||||
|
||||
/// Immutable backend-neutral render snapshot.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RenderSnapshot {
|
||||
/// Camera data for the frame.
|
||||
pub camera: CameraSnapshot,
|
||||
/// Draw candidates gathered from world/assets.
|
||||
pub draws: Vec<RenderSnapshotDraw>,
|
||||
}
|
||||
|
||||
/// Command generation profile.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct RenderProfile {
|
||||
/// Include UI phase commands when present.
|
||||
pub include_ui: bool,
|
||||
}
|
||||
|
||||
/// Draw command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawCommand {
|
||||
/// Draw id.
|
||||
pub id: DrawId,
|
||||
/// Phase.
|
||||
pub phase: RenderPhase,
|
||||
/// Object id.
|
||||
pub object_id: Option<OriginalObjectId>,
|
||||
/// Mesh.
|
||||
pub mesh: GpuMeshId,
|
||||
/// Material.
|
||||
pub material: GpuMaterialId,
|
||||
/// Transform matrix, row-major.
|
||||
pub transform: [f32; 16],
|
||||
/// Index range.
|
||||
pub range: IndexRange,
|
||||
/// Stable sort order.
|
||||
pub stable_order: u64,
|
||||
}
|
||||
|
||||
/// Render command.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum RenderCommand {
|
||||
/// Begin frame.
|
||||
BeginFrame,
|
||||
/// Draw.
|
||||
Draw(DrawCommand),
|
||||
/// End frame.
|
||||
EndFrame,
|
||||
}
|
||||
|
||||
/// Render command list.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RenderCommandList {
|
||||
/// Commands.
|
||||
pub commands: Vec<RenderCommand>,
|
||||
}
|
||||
|
||||
/// Frame output.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct FrameOutput;
|
||||
|
||||
/// Render error.
|
||||
#[derive(Debug)]
|
||||
pub enum RenderError {
|
||||
/// Invalid range.
|
||||
InvalidRange,
|
||||
/// Invalid draw range with command-generation context.
|
||||
InvalidDrawRange {
|
||||
/// Draw id.
|
||||
draw_id: DrawId,
|
||||
/// Stable sort order.
|
||||
stable_order: u64,
|
||||
/// Range start.
|
||||
start: u32,
|
||||
/// Range count.
|
||||
count: u32,
|
||||
},
|
||||
/// A batch material index did not resolve through the material table.
|
||||
MaterialIndexOutOfBounds {
|
||||
/// Draw id.
|
||||
draw_id: DrawId,
|
||||
/// Requested material index.
|
||||
material_index: u16,
|
||||
/// Available material slots.
|
||||
material_count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RenderError {}
|
||||
|
||||
/// Builds a deterministic command list from an immutable render snapshot.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RenderError`] when a draw has an invalid index range or a material
|
||||
/// index that cannot be resolved through its material slot table.
|
||||
pub fn build_commands(
|
||||
snapshot: &RenderSnapshot,
|
||||
profile: RenderProfile,
|
||||
) -> Result<RenderCommandList, RenderError> {
|
||||
let mut draws = snapshot
|
||||
.draws
|
||||
.iter()
|
||||
.filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui)
|
||||
.collect::<Vec<_>>();
|
||||
draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id));
|
||||
|
||||
let mut commands = Vec::with_capacity(draws.len() + 2);
|
||||
commands.push(RenderCommand::BeginFrame);
|
||||
for draw in draws {
|
||||
if draw.range.count == 0 {
|
||||
return Err(RenderError::InvalidDrawRange {
|
||||
draw_id: draw.id,
|
||||
stable_order: draw.stable_order,
|
||||
start: draw.range.start,
|
||||
count: draw.range.count,
|
||||
});
|
||||
}
|
||||
let material = draw
|
||||
.material_slots
|
||||
.get(usize::from(draw.material_index))
|
||||
.copied()
|
||||
.ok_or(RenderError::MaterialIndexOutOfBounds {
|
||||
draw_id: draw.id,
|
||||
material_index: draw.material_index,
|
||||
material_count: draw.material_slots.len(),
|
||||
})?;
|
||||
commands.push(RenderCommand::Draw(DrawCommand {
|
||||
id: draw.id,
|
||||
phase: draw.phase,
|
||||
object_id: draw.object_id,
|
||||
mesh: draw.mesh,
|
||||
material,
|
||||
transform: draw.transform,
|
||||
range: draw.range,
|
||||
stable_order: draw.stable_order,
|
||||
}));
|
||||
}
|
||||
commands.push(RenderCommand::EndFrame);
|
||||
Ok(RenderCommandList { commands })
|
||||
}
|
||||
|
||||
/// Backend port.
|
||||
pub trait RenderBackend {
|
||||
/// Executes commands.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RenderError`] when the command stream is malformed for the
|
||||
/// backend.
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
|
||||
}
|
||||
|
||||
/// Backend that validates commands and intentionally produces no pixels.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NullBackend;
|
||||
|
||||
impl RenderBackend for NullBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
validate_commands(commands)?;
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend that stores deterministic command captures for verification.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecordingBackend {
|
||||
captures: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RecordingBackend {
|
||||
/// Returns all captures in submission order.
|
||||
#[must_use]
|
||||
pub fn captures(&self) -> &[Vec<u8>] {
|
||||
&self.captures
|
||||
}
|
||||
|
||||
/// Returns the most recent capture.
|
||||
#[must_use]
|
||||
pub fn last_capture(&self) -> Option<&[u8]> {
|
||||
self.captures.last().map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Clears stored captures without changing backend behavior.
|
||||
pub fn clear(&mut self) {
|
||||
self.captures.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderBackend for RecordingBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
let capture = canonical_capture(commands)?;
|
||||
self.captures.push(capture);
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a canonical capture.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RenderError`] when a draw command contains an invalid index range.
|
||||
pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
|
||||
validate_commands(commands)?;
|
||||
let mut out = Vec::new();
|
||||
for command in &commands.commands {
|
||||
match command {
|
||||
RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"),
|
||||
RenderCommand::EndFrame => out.extend_from_slice(b"E\n"),
|
||||
RenderCommand::Draw(draw) => {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"D,{:?},{},{},{},{}\n",
|
||||
draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn validate_commands(commands: &RenderCommandList) -> Result<(), RenderError> {
|
||||
for command in &commands.commands {
|
||||
if let RenderCommand::Draw(draw) = command {
|
||||
if draw.range.count == 0 {
|
||||
return Err(RenderError::InvalidRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn identity_transform() -> [f32; 16] {
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot_draw(
|
||||
id: u64,
|
||||
phase: RenderPhase,
|
||||
material_index: u16,
|
||||
stable_order: u64,
|
||||
) -> RenderSnapshotDraw {
|
||||
RenderSnapshotDraw {
|
||||
id: DrawId(id),
|
||||
phase,
|
||||
object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))),
|
||||
mesh: GpuMeshId(10 + id),
|
||||
material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
|
||||
material_index,
|
||||
transform: identity_transform(),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_is_stable() {
|
||||
let list = RenderCommandList {
|
||||
commands: vec![
|
||||
RenderCommand::BeginFrame,
|
||||
RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(1),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 4,
|
||||
}),
|
||||
RenderCommand::EndFrame,
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
canonical_capture(&list).expect("capture"),
|
||||
b"B\nD,Opaque,1,2,3,4\nE\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_backend_validates_without_capture() {
|
||||
let mut backend = NullBackend;
|
||||
let invalid = RenderCommandList {
|
||||
commands: vec![RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(1),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 0 },
|
||||
stable_order: 4,
|
||||
})],
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
backend.execute(&invalid),
|
||||
Err(RenderError::InvalidRange)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_backend_stores_captures() {
|
||||
let mut backend = RecordingBackend::default();
|
||||
let list = RenderCommandList {
|
||||
commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame],
|
||||
};
|
||||
|
||||
backend.execute(&list).expect("execute");
|
||||
backend.execute(&list).expect("execute");
|
||||
|
||||
assert_eq!(backend.captures().len(), 2);
|
||||
assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..]));
|
||||
backend.clear();
|
||||
assert!(backend.captures().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> {
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
|
||||
};
|
||||
|
||||
let commands = build_commands(&snapshot, RenderProfile::default())?;
|
||||
|
||||
assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
|
||||
assert!(matches!(commands.commands[2], RenderCommand::EndFrame));
|
||||
let RenderCommand::Draw(draw) = &commands.commands[1] else {
|
||||
panic!("expected draw");
|
||||
};
|
||||
assert_eq!(draw.id, DrawId(1));
|
||||
assert_eq!(draw.mesh, GpuMeshId(11));
|
||||
assert_eq!(draw.range, IndexRange { start: 0, count: 3 });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> {
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)],
|
||||
};
|
||||
|
||||
let commands = build_commands(&snapshot, RenderProfile::default())?;
|
||||
|
||||
let RenderCommand::Draw(draw) = &commands.commands[1] else {
|
||||
panic!("expected draw");
|
||||
};
|
||||
assert_eq!(draw.material, GpuMaterialId(37));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_transform_is_retained() -> Result<(), RenderError> {
|
||||
let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10);
|
||||
draw.transform[3] = 12.5;
|
||||
draw.transform[7] = -4.0;
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![draw],
|
||||
};
|
||||
|
||||
let commands = build_commands(&snapshot, RenderProfile::default())?;
|
||||
|
||||
let RenderCommand::Draw(draw) = &commands.commands[1] else {
|
||||
panic!("expected draw");
|
||||
};
|
||||
assert_eq!(draw.transform[3], 12.5);
|
||||
assert_eq!(draw.transform[7], -4.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> {
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![
|
||||
snapshot_draw(3, RenderPhase::Transparent, 0, 0),
|
||||
snapshot_draw(2, RenderPhase::Opaque, 0, 20),
|
||||
snapshot_draw(1, RenderPhase::Opaque, 0, 10),
|
||||
],
|
||||
};
|
||||
|
||||
let commands = build_commands(&snapshot, RenderProfile::default())?;
|
||||
let capture = canonical_capture(&commands)?;
|
||||
|
||||
assert_eq!(
|
||||
capture,
|
||||
b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> {
|
||||
let forward = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![
|
||||
snapshot_draw(1, RenderPhase::Opaque, 0, 10),
|
||||
snapshot_draw(2, RenderPhase::Opaque, 1, 20),
|
||||
],
|
||||
};
|
||||
let reverse = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![
|
||||
snapshot_draw(2, RenderPhase::Opaque, 1, 20),
|
||||
snapshot_draw(1, RenderPhase::Opaque, 0, 10),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
canonical_capture(&build_commands(&forward, RenderProfile::default())?)?,
|
||||
canonical_capture(&build_commands(&reverse, RenderProfile::default())?)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_range_returns_contextual_error() {
|
||||
let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10);
|
||||
draw.range = IndexRange { start: 4, count: 0 };
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![draw],
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
build_commands(&snapshot, RenderProfile::default()),
|
||||
Err(RenderError::InvalidDrawRange {
|
||||
draw_id: DrawId(9),
|
||||
stable_order: 10,
|
||||
start: 4,
|
||||
count: 0
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![
|
||||
snapshot_draw(1, RenderPhase::Opaque, 0, 10),
|
||||
snapshot_draw(2, RenderPhase::Ui, 0, 20),
|
||||
],
|
||||
};
|
||||
|
||||
let default_commands = build_commands(&snapshot, RenderProfile::default())?;
|
||||
let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?;
|
||||
|
||||
assert_eq!(default_commands.commands.len(), 3);
|
||||
assert_eq!(ui_commands.commands.len(), 4);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-resource"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,880 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Resource identity and repository ports.
|
||||
|
||||
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
|
||||
use fparkan_vfs::{Vfs, VfsError};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Resource key.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResourceKey {
|
||||
/// Archive path.
|
||||
pub archive: NormalizedPath,
|
||||
/// Entry name.
|
||||
pub name: ResourceName,
|
||||
/// Optional type id.
|
||||
pub type_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Resource entry metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResourceEntryInfo {
|
||||
/// Stable resource key.
|
||||
pub key: ResourceKey,
|
||||
/// Archive entry attribute 1.
|
||||
pub attr1: u32,
|
||||
/// Archive entry attribute 2.
|
||||
pub attr2: u32,
|
||||
/// Archive entry attribute 3.
|
||||
pub attr3: u32,
|
||||
}
|
||||
|
||||
/// Archive identity.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct ArchiveId(pub u64);
|
||||
|
||||
/// Entry handle.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct EntryHandle {
|
||||
/// Archive.
|
||||
pub archive: ArchiveId,
|
||||
/// Local entry index.
|
||||
pub local: u32,
|
||||
}
|
||||
|
||||
/// Archive kind.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ArchiveKind {
|
||||
/// `NRes` archive.
|
||||
Nres,
|
||||
/// `RsLi` archive.
|
||||
Rsli,
|
||||
}
|
||||
|
||||
/// Resource bytes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResourceBytes {
|
||||
/// Shared byte owner.
|
||||
Shared(Arc<[u8]>),
|
||||
/// Slice in owner.
|
||||
Slice {
|
||||
/// Shared owner bytes.
|
||||
owner: Arc<[u8]>,
|
||||
/// Slice range.
|
||||
range: Range<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResourceBytes {
|
||||
/// Returns a byte slice.
|
||||
#[must_use]
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Shared(bytes) => bytes,
|
||||
Self::Slice { owner, range } => &owner[range.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns byte length.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.as_slice().len()
|
||||
}
|
||||
|
||||
/// Returns whether the resource is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Returns owned bytes.
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::Shared(bytes) => bytes.to_vec(),
|
||||
Self::Slice { owner, range } => owner[range].to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource error.
|
||||
#[derive(Debug)]
|
||||
pub enum ResourceError {
|
||||
/// Missing archive.
|
||||
MissingArchive,
|
||||
/// Missing entry.
|
||||
MissingEntry,
|
||||
/// Stale or invalid handle.
|
||||
InvalidHandle,
|
||||
/// Format error.
|
||||
Format(String),
|
||||
/// Entry-specific read error.
|
||||
EntryRead {
|
||||
/// Resource key.
|
||||
key: ResourceKey,
|
||||
/// Source error text.
|
||||
source: String,
|
||||
},
|
||||
/// Repository state lock was poisoned.
|
||||
Poisoned,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResourceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ResourceError {}
|
||||
|
||||
/// Repository port.
|
||||
pub trait ResourceRepository {
|
||||
/// Opens archive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when the archive is missing, unsupported, or
|
||||
/// malformed.
|
||||
fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError>;
|
||||
/// Finds entry.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `archive` is not a valid opened archive.
|
||||
fn find(
|
||||
&self,
|
||||
archive: ArchiveId,
|
||||
name: &ResourceName,
|
||||
) -> Result<Option<EntryHandle>, ResourceError>;
|
||||
/// Reads bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `entry` is stale, invalid, or cannot be
|
||||
/// decoded.
|
||||
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError>;
|
||||
/// Reads entry metadata.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError`] when `entry` is stale or invalid.
|
||||
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError>;
|
||||
}
|
||||
|
||||
/// Cached archive repository over a [`Vfs`].
|
||||
pub struct CachedResourceRepository {
|
||||
vfs: Arc<dyn Vfs>,
|
||||
state: Mutex<RepositoryState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RepositoryState {
|
||||
paths: BTreeMap<String, ArchiveId>,
|
||||
archives: Vec<ArchiveSlot>,
|
||||
payload_cache: DecodedPayloadCache,
|
||||
}
|
||||
|
||||
struct ArchiveSlot {
|
||||
path: NormalizedPath,
|
||||
fingerprint: u64,
|
||||
kind: ArchiveKind,
|
||||
document: ArchiveDocument,
|
||||
}
|
||||
|
||||
enum ArchiveDocument {
|
||||
Nres(fparkan_nres::NresDocument),
|
||||
Rsli(fparkan_rsli::RsliDocument),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DecodedPayloadCache {
|
||||
max_entries: usize,
|
||||
generation: u64,
|
||||
entries: BTreeMap<EntryHandle, PayloadCacheEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PayloadCacheEntry {
|
||||
bytes: Arc<[u8]>,
|
||||
last_access: u64,
|
||||
}
|
||||
|
||||
impl CachedResourceRepository {
|
||||
/// Creates a cached repository.
|
||||
#[must_use]
|
||||
pub fn new(vfs: Arc<dyn Vfs>) -> Self {
|
||||
Self::with_payload_cache_budget(vfs, 64)
|
||||
}
|
||||
|
||||
/// Creates a cached repository with a decoded payload entry budget.
|
||||
#[must_use]
|
||||
pub fn with_payload_cache_budget(vfs: Arc<dyn Vfs>, max_payload_entries: usize) -> Self {
|
||||
Self {
|
||||
vfs,
|
||||
state: Mutex::new(RepositoryState {
|
||||
payload_cache: DecodedPayloadCache::new(max_payload_entries),
|
||||
..RepositoryState::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the archive kind for an opened archive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError::InvalidHandle`] when `archive` is not present.
|
||||
pub fn archive_kind(&self, archive: ArchiveId) -> Result<ArchiveKind, ResourceError> {
|
||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
Ok(state.archive(archive)?.kind)
|
||||
}
|
||||
|
||||
/// Returns the archive path for an opened archive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError::InvalidHandle`] when `archive` is not present.
|
||||
pub fn archive_path(&self, archive: ArchiveId) -> Result<NormalizedPath, ResourceError> {
|
||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
Ok(state.archive(archive)?.path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceRepository for CachedResourceRepository {
|
||||
fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> {
|
||||
let metadata = self.vfs.metadata(path).map_err(resource_error_from_vfs)?;
|
||||
let fingerprint = metadata.fingerprint;
|
||||
if let Some(id) = self.cached_id(path, fingerprint)? {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
let bytes = self.vfs.read(path).map_err(resource_error_from_vfs)?;
|
||||
let slot = decode_archive(path.clone(), bytes, fingerprint)?;
|
||||
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
if let Some(id) = state.paths.get(path.as_str()).copied() {
|
||||
if state.archive(id)?.fingerprint == fingerprint {
|
||||
return Ok(id);
|
||||
}
|
||||
*state.archive_mut(id)? = slot;
|
||||
state.payload_cache.remove_archive(id);
|
||||
return Ok(id);
|
||||
}
|
||||
let id = ArchiveId(u64::try_from(state.archives.len()).map_err(|_| {
|
||||
ResourceError::Format("too many open archives for handle space".to_string())
|
||||
})?);
|
||||
state.paths.insert(path.as_str().to_string(), id);
|
||||
state.archives.push(slot);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn find(
|
||||
&self,
|
||||
archive: ArchiveId,
|
||||
name: &ResourceName,
|
||||
) -> Result<Option<EntryHandle>, ResourceError> {
|
||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
let slot = state.archive(archive)?;
|
||||
let local = match &slot.document {
|
||||
ArchiveDocument::Nres(document) => document.find_bytes(&name.0).map(|id| id.0),
|
||||
ArchiveDocument::Rsli(document) => document.find_bytes(&name.0).map(|id| id.0),
|
||||
};
|
||||
Ok(local.map(|local| EntryHandle { archive, local }))
|
||||
}
|
||||
|
||||
fn read(&self, entry: EntryHandle) -> Result<ResourceBytes, ResourceError> {
|
||||
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
if let Some(bytes) = state.payload_cache.get(entry) {
|
||||
return Ok(ResourceBytes::Shared(bytes));
|
||||
}
|
||||
|
||||
let payload = {
|
||||
let slot = state.archive(entry.archive)?;
|
||||
let key = slot.entry_key(entry.local)?;
|
||||
slot.read_payload(entry.local)
|
||||
.map_err(|source| ResourceError::EntryRead {
|
||||
key: key.clone(),
|
||||
source,
|
||||
})?
|
||||
};
|
||||
let shared = Arc::from(payload.into_boxed_slice());
|
||||
state.payload_cache.insert(entry, Arc::clone(&shared));
|
||||
Ok(ResourceBytes::Shared(shared))
|
||||
}
|
||||
|
||||
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
|
||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
let slot = state.archive(entry.archive)?;
|
||||
match &slot.document {
|
||||
ArchiveDocument::Nres(document) => {
|
||||
let local =
|
||||
usize::try_from(entry.local).map_err(|_| ResourceError::InvalidHandle)?;
|
||||
let entry = document
|
||||
.entries()
|
||||
.get(local)
|
||||
.ok_or(ResourceError::InvalidHandle)?;
|
||||
let meta = entry.meta();
|
||||
Ok(ResourceEntryInfo {
|
||||
key: ResourceKey {
|
||||
archive: slot.path.clone(),
|
||||
name: ResourceName(entry.name_bytes().to_vec()),
|
||||
type_id: Some(meta.type_id),
|
||||
},
|
||||
attr1: meta.attr1,
|
||||
attr2: meta.attr2,
|
||||
attr3: meta.attr3,
|
||||
})
|
||||
}
|
||||
ArchiveDocument::Rsli(document) => {
|
||||
let meta = document
|
||||
.entry(fparkan_rsli::EntryId(entry.local))
|
||||
.ok_or(ResourceError::InvalidHandle)?;
|
||||
Ok(ResourceEntryInfo {
|
||||
key: ResourceKey {
|
||||
archive: slot.path.clone(),
|
||||
name: ResourceName(meta.name_raw.to_vec()),
|
||||
type_id: None,
|
||||
},
|
||||
attr1: u32::try_from(meta.flags).unwrap_or_default(),
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedResourceRepository {
|
||||
fn cached_id(
|
||||
&self,
|
||||
path: &NormalizedPath,
|
||||
fingerprint: u64,
|
||||
) -> Result<Option<ArchiveId>, ResourceError> {
|
||||
let state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
|
||||
let Some(id) = state.paths.get(path.as_str()).copied() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if state.archive(id)?.fingerprint == fingerprint {
|
||||
Ok(Some(id))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodedPayloadCache {
|
||||
fn new(max_entries: usize) -> Self {
|
||||
Self {
|
||||
max_entries,
|
||||
generation: 0,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&mut self, handle: EntryHandle) -> Option<Arc<[u8]>> {
|
||||
let entry = self.entries.get_mut(&handle)?;
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
entry.last_access = self.generation;
|
||||
Some(Arc::clone(&entry.bytes))
|
||||
}
|
||||
|
||||
fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
|
||||
if self.max_entries == 0 {
|
||||
return;
|
||||
}
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
self.entries.insert(
|
||||
handle,
|
||||
PayloadCacheEntry {
|
||||
bytes,
|
||||
last_access: self.generation,
|
||||
},
|
||||
);
|
||||
while self.entries.len() > self.max_entries {
|
||||
let Some(victim) = self
|
||||
.entries
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.last_access)
|
||||
.map(|(handle, _)| *handle)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
self.entries.remove(&victim);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_archive(&mut self, archive: ArchiveId) {
|
||||
self.entries.retain(|handle, _| handle.archive != archive);
|
||||
}
|
||||
}
|
||||
|
||||
impl RepositoryState {
|
||||
fn archive(&self, id: ArchiveId) -> Result<&ArchiveSlot, ResourceError> {
|
||||
let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?;
|
||||
self.archives.get(index).ok_or(ResourceError::InvalidHandle)
|
||||
}
|
||||
|
||||
fn archive_mut(&mut self, id: ArchiveId) -> Result<&mut ArchiveSlot, ResourceError> {
|
||||
let index = usize::try_from(id.0).map_err(|_| ResourceError::InvalidHandle)?;
|
||||
self.archives
|
||||
.get_mut(index)
|
||||
.ok_or(ResourceError::InvalidHandle)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchiveSlot {
|
||||
fn entry_key(&self, local: u32) -> Result<ResourceKey, ResourceError> {
|
||||
match &self.document {
|
||||
ArchiveDocument::Nres(document) => {
|
||||
let local = usize::try_from(local).map_err(|_| ResourceError::InvalidHandle)?;
|
||||
let entry = document
|
||||
.entries()
|
||||
.get(local)
|
||||
.ok_or(ResourceError::InvalidHandle)?;
|
||||
Ok(ResourceKey {
|
||||
archive: self.path.clone(),
|
||||
name: ResourceName(entry.name_bytes().to_vec()),
|
||||
type_id: Some(entry.meta().type_id),
|
||||
})
|
||||
}
|
||||
ArchiveDocument::Rsli(document) => {
|
||||
let meta = document
|
||||
.entry(fparkan_rsli::EntryId(local))
|
||||
.ok_or(ResourceError::InvalidHandle)?;
|
||||
Ok(ResourceKey {
|
||||
archive: self.path.clone(),
|
||||
name: ResourceName(c_name_bytes(&meta.name_raw).to_vec()),
|
||||
type_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
|
||||
match &self.document {
|
||||
ArchiveDocument::Nres(document) => document
|
||||
.payload(fparkan_nres::EntryId(local))
|
||||
.map(<[u8]>::to_vec)
|
||||
.map_err(|err| err.to_string()),
|
||||
ArchiveDocument::Rsli(document) => document
|
||||
.load(fparkan_rsli::EntryId(local))
|
||||
.map_err(|err| err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_archive(
|
||||
path: NormalizedPath,
|
||||
bytes: Arc<[u8]>,
|
||||
fingerprint: u64,
|
||||
) -> Result<ArchiveSlot, ResourceError> {
|
||||
if bytes.starts_with(b"NRes") {
|
||||
let document = fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible)
|
||||
.map_err(|err| ResourceError::Format(err.to_string()))?;
|
||||
return Ok(ArchiveSlot {
|
||||
path,
|
||||
fingerprint,
|
||||
kind: ArchiveKind::Nres,
|
||||
document: ArchiveDocument::Nres(document),
|
||||
});
|
||||
}
|
||||
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||
let document = fparkan_rsli::decode(bytes, fparkan_rsli::ReadProfile::Compatible)
|
||||
.map_err(|err| ResourceError::Format(err.to_string()))?;
|
||||
return Ok(ArchiveSlot {
|
||||
path,
|
||||
fingerprint,
|
||||
kind: ArchiveKind::Rsli,
|
||||
document: ArchiveDocument::Rsli(document),
|
||||
});
|
||||
}
|
||||
Err(ResourceError::Format(
|
||||
"unsupported archive magic for resource repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resource_error_from_vfs(err: VfsError) -> ResourceError {
|
||||
match err {
|
||||
VfsError::NotFound(_) => ResourceError::MissingArchive,
|
||||
VfsError::Ambiguous(path) => ResourceError::Format(format!("ambiguous VFS path: {path}")),
|
||||
VfsError::Io(source) => ResourceError::Format(source.to_string()),
|
||||
VfsError::Path => ResourceError::Format("invalid VFS path".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a resource name from raw bytes.
|
||||
#[must_use]
|
||||
pub fn resource_name(raw: impl AsRef<[u8]>) -> ResourceName {
|
||||
ResourceName(raw.as_ref().to_vec())
|
||||
}
|
||||
|
||||
/// Normalizes an archive path for resource lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ResourceError::Format`] when the path is not a valid relative
|
||||
/// resource path.
|
||||
pub fn archive_path(raw: impl AsRef<[u8]>) -> Result<NormalizedPath, ResourceError> {
|
||||
normalize_relative(raw.as_ref(), PathPolicy::StrictLegacy)
|
||||
.map_err(|err| ResourceError::Format(err.to_string()))
|
||||
}
|
||||
|
||||
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_vfs::{DirectoryVfs, MemoryVfs};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn cached_repository_reads_synthetic_nres() {
|
||||
let path = archive_path(b"archives/test.lib").expect("path");
|
||||
let bytes = build_nres(&[("Alpha.TXT", b"alpha".as_slice()), ("beta.bin", b"beta")]);
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
|
||||
let repo = CachedResourceRepository::new(Arc::new(vfs));
|
||||
|
||||
let first = repo.open_archive(&path).expect("open archive");
|
||||
let second = repo.open_archive(&path).expect("open archive again");
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(repo.archive_kind(first).expect("kind"), ArchiveKind::Nres);
|
||||
|
||||
let handle = repo
|
||||
.find(first, &resource_name(b"alpha.txt"))
|
||||
.expect("find")
|
||||
.expect("entry");
|
||||
assert_eq!(repo.read(handle).expect("read").as_slice(), b"alpha");
|
||||
let info = repo.entry_info(handle).expect("entry info");
|
||||
assert_eq!(info.key.archive, path);
|
||||
assert!(info.key.name.0.eq_ignore_ascii_case(b"Alpha.TXT"));
|
||||
assert!(matches!(
|
||||
repo.read(EntryHandle {
|
||||
archive: ArchiveId(99),
|
||||
local: 0
|
||||
}),
|
||||
Err(ResourceError::InvalidHandle)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_handles_are_archive_qualified() {
|
||||
let first_path = archive_path(b"first.lib").expect("first path");
|
||||
let second_path = archive_path(b"second.lib").expect("second path");
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(
|
||||
first_path.clone(),
|
||||
Arc::from(build_nres(&[("same.bin", b"first".as_slice())]).into_boxed_slice()),
|
||||
);
|
||||
vfs.insert(
|
||||
second_path.clone(),
|
||||
Arc::from(build_nres(&[("same.bin", b"second".as_slice())]).into_boxed_slice()),
|
||||
);
|
||||
let repo = CachedResourceRepository::new(Arc::new(vfs));
|
||||
|
||||
let first_archive = repo.open_archive(&first_path).expect("first archive");
|
||||
let second_archive = repo.open_archive(&second_path).expect("second archive");
|
||||
let first_handle = repo
|
||||
.find(first_archive, &resource_name(b"same.bin"))
|
||||
.expect("first find")
|
||||
.expect("first handle");
|
||||
let second_handle = repo
|
||||
.find(second_archive, &resource_name(b"same.bin"))
|
||||
.expect("second find")
|
||||
.expect("second handle");
|
||||
|
||||
assert_ne!(first_handle, second_handle);
|
||||
assert_eq!(first_handle.archive, first_archive);
|
||||
assert_eq!(second_handle.archive, second_archive);
|
||||
assert_eq!(
|
||||
repo.read(first_handle).expect("first read").as_slice(),
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
repo.read(second_handle).expect("second read").as_slice(),
|
||||
b"second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_cache_and_decoded_payload_cache_evict_independently() {
|
||||
let path = archive_path(b"cache/test.lib").expect("path");
|
||||
let bytes = build_nres(&[("a.bin", b"a".as_slice()), ("b.bin", b"b".as_slice())]);
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(path.clone(), Arc::from(bytes.into_boxed_slice()));
|
||||
let repo = CachedResourceRepository::with_payload_cache_budget(Arc::new(vfs), 1);
|
||||
|
||||
let archive = repo.open_archive(&path).expect("open archive");
|
||||
let first = repo
|
||||
.find(archive, &resource_name(b"a.bin"))
|
||||
.expect("find a")
|
||||
.expect("a");
|
||||
let second = repo
|
||||
.find(archive, &resource_name(b"b.bin"))
|
||||
.expect("find b")
|
||||
.expect("b");
|
||||
assert_eq!(repo.read(first).expect("read a").as_slice(), b"a");
|
||||
assert_eq!(repo.read(second).expect("read b").as_slice(), b"b");
|
||||
|
||||
let state = repo.state.lock().expect("state");
|
||||
assert_eq!(state.archives.len(), 1);
|
||||
assert_eq!(state.payload_cache.entries.len(), 1);
|
||||
assert_eq!(state.paths.get(path.as_str()).copied(), Some(archive));
|
||||
drop(state);
|
||||
|
||||
assert_eq!(repo.open_archive(&path).expect("cached archive"), archive);
|
||||
assert_eq!(
|
||||
repo.read(first).expect("reread evicted payload").as_slice(),
|
||||
b"a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_cache_invalidates_when_vfs_bytes_change() {
|
||||
let root = temp_dir("archive-invalidate");
|
||||
let path = archive_path(b"cache/test.lib").expect("path");
|
||||
let host_path = root.join(path.as_str());
|
||||
std::fs::create_dir_all(host_path.parent().expect("parent")).expect("cache dir");
|
||||
std::fs::write(&host_path, build_nres(&[("a.bin", b"before".as_slice())]))
|
||||
.expect("initial archive");
|
||||
let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root)));
|
||||
|
||||
let archive = repo.open_archive(&path).expect("open initial archive");
|
||||
let first = repo
|
||||
.find(archive, &resource_name(b"a.bin"))
|
||||
.expect("find initial")
|
||||
.expect("initial handle");
|
||||
assert_eq!(
|
||||
repo.read(first).expect("read initial").as_slice(),
|
||||
b"before"
|
||||
);
|
||||
|
||||
std::fs::write(&host_path, build_nres(&[("a.bin", b"after".as_slice())]))
|
||||
.expect("updated archive");
|
||||
let reopened = repo.open_archive(&path).expect("open updated archive");
|
||||
let second = repo
|
||||
.find(reopened, &resource_name(b"a.bin"))
|
||||
.expect("find updated")
|
||||
.expect("updated handle");
|
||||
|
||||
assert_eq!(reopened, archive);
|
||||
assert_eq!(
|
||||
repo.read(second).expect("read updated").as_slice(),
|
||||
b"after"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_read_error_carries_archive_path_and_entry_name() {
|
||||
let path = archive_path(b"bad/rsli.lib").expect("path");
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(
|
||||
path.clone(),
|
||||
Arc::from(build_rsli_unknown_method(b"BROKEN.TEX", b"x").into_boxed_slice()),
|
||||
);
|
||||
let repo = CachedResourceRepository::new(Arc::new(vfs));
|
||||
let archive = repo.open_archive(&path).expect("open bad archive");
|
||||
let handle = repo
|
||||
.find(archive, &resource_name(b"BROKEN.TEX"))
|
||||
.expect("find bad entry")
|
||||
.expect("bad handle");
|
||||
|
||||
let err = repo.read(handle).expect_err("read should fail");
|
||||
|
||||
match err {
|
||||
ResourceError::EntryRead { key, source } => {
|
||||
assert_eq!(key.archive, path);
|
||||
assert_eq!(key.name.0, b"BROKEN.TEX");
|
||||
assert!(source.contains("unsupported packing method"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn licensed_corpora_repository_reads_nres_and_rsli() {
|
||||
licensed_repository_gate("IS").expect("part 1 repository gate");
|
||||
licensed_repository_gate("IS2").expect("part 2 repository gate");
|
||||
}
|
||||
|
||||
fn licensed_repository_gate(corpus: &str) -> Result<(), String> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("testdata")
|
||||
.join(corpus);
|
||||
if !root.is_dir() {
|
||||
return Err(format!(
|
||||
"licensed corpus root is missing: {}",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
let repo = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&root)));
|
||||
|
||||
let material_path = archive_path(b"Material.lib").map_err(|err| err.to_string())?;
|
||||
let material_bytes =
|
||||
std::fs::read(root.join(material_path.as_str())).map_err(|err| err.to_string())?;
|
||||
let material_doc = fparkan_nres::decode(
|
||||
Arc::from(material_bytes.clone().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let material_entry = material_doc
|
||||
.entries()
|
||||
.first()
|
||||
.ok_or_else(|| "Material.lib has no entries".to_string())?;
|
||||
|
||||
let material_archive = repo
|
||||
.open_archive(&material_path)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let material_handle = repo
|
||||
.find(
|
||||
material_archive,
|
||||
&resource_name(material_entry.name_bytes()),
|
||||
)
|
||||
.map_err(|err| err.to_string())?
|
||||
.ok_or_else(|| "Material.lib first entry not found".to_string())?;
|
||||
let material_payload = repo
|
||||
.read(material_handle)
|
||||
.map_err(|err| err.to_string())?
|
||||
.into_owned();
|
||||
let expected_material = material_doc
|
||||
.payload(material_entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
if material_payload != expected_material {
|
||||
return Err("Material.lib payload mismatch".to_string());
|
||||
}
|
||||
|
||||
let font_path = archive_path(b"gamefont.rlb").map_err(|err| err.to_string())?;
|
||||
let font_bytes =
|
||||
std::fs::read(root.join(font_path.as_str())).map_err(|err| err.to_string())?;
|
||||
let font_doc = fparkan_rsli::decode(
|
||||
Arc::from(font_bytes.into_boxed_slice()),
|
||||
fparkan_rsli::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let font_entry = font_doc
|
||||
.entries()
|
||||
.first()
|
||||
.ok_or_else(|| "gamefont.rlb has no entries".to_string())?;
|
||||
let font_archive = repo
|
||||
.open_archive(&font_path)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let font_handle = repo
|
||||
.find(font_archive, &resource_name(font_entry.name_raw))
|
||||
.map_err(|err| err.to_string())?
|
||||
.ok_or_else(|| "gamefont.rlb first entry not found".to_string())?;
|
||||
let font_payload = repo
|
||||
.read(font_handle)
|
||||
.map_err(|err| err.to_string())?
|
||||
.into_owned();
|
||||
let expected_font = font_doc
|
||||
.load(fparkan_rsli::EntryId(0))
|
||||
.map_err(|err| err.to_string())?;
|
||||
if font_payload != expected_font {
|
||||
return Err("gamefont.rlb payload mismatch".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_nres(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut out = vec![0; 16];
|
||||
let mut offsets = Vec::with_capacity(entries.len());
|
||||
for (_, payload) in entries {
|
||||
offsets.push(u32::try_from(out.len()).expect("offset"));
|
||||
out.extend_from_slice(payload);
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
let mut order: Vec<usize> = (0..entries.len()).collect();
|
||||
order.sort_by(|left, right| {
|
||||
entries[*left]
|
||||
.0
|
||||
.as_bytes()
|
||||
.cmp(entries[*right].0.as_bytes())
|
||||
});
|
||||
for (idx, (name, payload)) in entries.iter().enumerate() {
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(
|
||||
&mut out,
|
||||
u32::try_from(payload.len()).expect("payload size"),
|
||||
);
|
||||
push_u32(&mut out, 0);
|
||||
let mut name_raw = [0; 36];
|
||||
name_raw[..name.len()].copy_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(&name_raw);
|
||||
push_u32(&mut out, offsets[idx]);
|
||||
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
|
||||
}
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
||||
let total_size = u32::try_from(out.len()).expect("total size");
|
||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"fparkan-resource-{name}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
fn build_rsli_unknown_method(name: &[u8], payload: &[u8]) -> Vec<u8> {
|
||||
let mut header = [0u8; 32];
|
||||
header[0..4].copy_from_slice(b"NL\0\x01");
|
||||
header[4..6].copy_from_slice(&1i16.to_le_bytes());
|
||||
header[14..16].copy_from_slice(&0xABBAu16.to_le_bytes());
|
||||
header[20..24].copy_from_slice(&0x1234u32.to_le_bytes());
|
||||
|
||||
let mut row = [0u8; 32];
|
||||
let name_len = name.len().min(12);
|
||||
row[0..name_len].copy_from_slice(&name[..name_len]);
|
||||
row[16..18].copy_from_slice(&0x1E0i16.to_le_bytes());
|
||||
row[20..24].copy_from_slice(
|
||||
&u32::try_from(payload.len())
|
||||
.expect("rsli unpacked size")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
row[24..28].copy_from_slice(&64u32.to_le_bytes());
|
||||
row[28..32].copy_from_slice(
|
||||
&u32::try_from(payload.len())
|
||||
.expect("rsli packed size")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&header);
|
||||
out.extend_from_slice(&test_xor_stream(&row, 0x1234));
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
fn test_xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
|
||||
let mut lo = u8::try_from(key16 & 0xFF).expect("lo");
|
||||
let mut hi = u8::try_from((key16 >> 8) & 0xFF).expect("hi");
|
||||
data.iter()
|
||||
.map(|byte| {
|
||||
lo = hi ^ lo.wrapping_shl(1);
|
||||
let transformed = byte ^ lo;
|
||||
hi = lo ^ (hi >> 1);
|
||||
transformed
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-rsli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "fparkan-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-platform = { path = "../fparkan-platform" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-render = { path = "../fparkan-render" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-terrain-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-terrain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-test-support"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../fparkan-render" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,25 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Dev-only synthetic builders and fake ports.
|
||||
|
||||
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
||||
|
||||
/// Fake clock.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct FakeClock {
|
||||
/// Current tick.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// Recording backend.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecordingRenderBackend {
|
||||
/// Recorded command lists.
|
||||
pub captures: Vec<RenderCommandList>,
|
||||
}
|
||||
|
||||
impl RenderBackend for RecordingRenderBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
self.captures.push(commands.clone());
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-texm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-vfs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,456 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Virtual filesystem ports for resource loading.
|
||||
|
||||
use fparkan_path::{join_under, NormalizedPath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VfsMetadata {
|
||||
/// Byte length.
|
||||
pub len: u64,
|
||||
/// Stable-enough source fingerprint for cache invalidation.
|
||||
pub fingerprint: u64,
|
||||
}
|
||||
|
||||
/// VFS entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VfsEntry {
|
||||
/// Path.
|
||||
pub path: NormalizedPath,
|
||||
/// Metadata.
|
||||
pub metadata: VfsMetadata,
|
||||
}
|
||||
|
||||
/// VFS error.
|
||||
#[derive(Debug)]
|
||||
pub enum VfsError {
|
||||
/// Missing entry.
|
||||
NotFound(String),
|
||||
/// Ambiguous host path.
|
||||
Ambiguous(String),
|
||||
/// I/O error.
|
||||
Io(std::io::Error),
|
||||
/// Invalid path.
|
||||
Path,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VfsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(path) => write!(f, "not found: {path}"),
|
||||
Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"),
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
Self::Path => write!(f, "invalid path"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VfsError {}
|
||||
|
||||
/// Resource VFS.
|
||||
pub trait Vfs: Send + Sync {
|
||||
/// Reads metadata.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
|
||||
/// inspected by the backing store.
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError>;
|
||||
/// Reads bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
|
||||
/// read by the backing store.
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError>;
|
||||
/// Lists entries below prefix.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be
|
||||
/// traversed by the backing store.
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError>;
|
||||
}
|
||||
|
||||
/// Host directory VFS.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DirectoryVfs {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl DirectoryVfs {
|
||||
/// Creates a directory VFS.
|
||||
#[must_use]
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||
if exact.exists() {
|
||||
return Ok(exact);
|
||||
}
|
||||
resolve_casefolded(&self.root, path.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Vfs for DirectoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
|
||||
Ok(metadata_from_fs(&meta))
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?;
|
||||
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
let base = self.host_path(prefix)?;
|
||||
let mut entries = Vec::new();
|
||||
if base.is_file() {
|
||||
let metadata = fs::metadata(&base).map_err(VfsError::Io)?;
|
||||
entries.push(VfsEntry {
|
||||
path: prefix.clone(),
|
||||
metadata: metadata_from_fs(&metadata),
|
||||
});
|
||||
return Ok(entries);
|
||||
}
|
||||
list_recursive(&self.root, &base, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||
let mut current = root.to_path_buf();
|
||||
for segment in normalized.split('/') {
|
||||
let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?;
|
||||
let mut matches = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(VfsError::Io)?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if name.eq_ignore_ascii_case(segment) {
|
||||
matches.push(entry.path());
|
||||
}
|
||||
}
|
||||
current = select_casefolded_match(normalized, ¤t, segment, matches)?;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn select_casefolded_match(
|
||||
normalized: &str,
|
||||
current: &Path,
|
||||
segment: &str,
|
||||
mut matches: Vec<PathBuf>,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
matches.sort();
|
||||
match matches.len() {
|
||||
0 => Err(VfsError::NotFound(normalized.to_string())),
|
||||
1 => Ok(matches.remove(0)),
|
||||
_ => Err(VfsError::Ambiguous(format!(
|
||||
"{}/{}",
|
||||
current.display(),
|
||||
segment
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
|
||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||
let mut children = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(VfsError::Io)?;
|
||||
children.push(entry.path());
|
||||
}
|
||||
children.sort();
|
||||
for child in children {
|
||||
let metadata = fs::metadata(&child).map_err(VfsError::Io)?;
|
||||
if metadata.is_dir() {
|
||||
list_recursive(root, &child, out)?;
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
|
||||
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
|
||||
let path = fparkan_path::normalize_relative(
|
||||
rel_text.as_bytes(),
|
||||
fparkan_path::PathPolicy::HostCompatible,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path,
|
||||
metadata: metadata_from_fs(&metadata),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata {
|
||||
let mut fingerprint = 0xcbf2_9ce4_8422_2325;
|
||||
hash_u64(&mut fingerprint, metadata.len());
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
|
||||
hash_u64(&mut fingerprint, duration.as_secs());
|
||||
hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos()));
|
||||
}
|
||||
}
|
||||
VfsMetadata {
|
||||
len: metadata.len(),
|
||||
fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory VFS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryVfs {
|
||||
files: BTreeMap<String, Arc<[u8]>>,
|
||||
}
|
||||
|
||||
impl MemoryVfs {
|
||||
/// Inserts a file.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||
self.files.insert(path.as_str().to_string(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl Vfs for MemoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let bytes = self
|
||||
.files
|
||||
.get(path.as_str())
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
Ok(VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: stable_hash(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
self.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
let mut out = Vec::new();
|
||||
for (path, bytes) in &self.files {
|
||||
if path
|
||||
.as_bytes()
|
||||
.get(..prefix.as_str().len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
|
||||
{
|
||||
let normalized = fparkan_path::normalize_relative(
|
||||
path.as_bytes(),
|
||||
fparkan_path::PathPolicy::StrictLegacy,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path: normalized,
|
||||
metadata: VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: stable_hash(bytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
||||
let mut state = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in bytes {
|
||||
state ^= u64::from(*byte);
|
||||
state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn hash_u64(state: &mut u64, value: u64) {
|
||||
for byte in value.to_le_bytes() {
|
||||
*state ^= u64::from(byte);
|
||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layered VFS with deterministic first-layer precedence.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OverlayVfs {
|
||||
layers: Vec<Arc<dyn Vfs>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OverlayVfs {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OverlayVfs")
|
||||
.field("layers", &self.layers.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OverlayVfs {
|
||||
/// Creates an empty overlay.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Creates an overlay from ordered layers.
|
||||
#[must_use]
|
||||
pub fn from_layers(layers: Vec<Arc<dyn Vfs>>) -> Self {
|
||||
Self { layers }
|
||||
}
|
||||
|
||||
/// Appends a lower-priority layer.
|
||||
pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
|
||||
self.layers.push(layer);
|
||||
}
|
||||
}
|
||||
|
||||
impl Vfs for OverlayVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
for layer in &self.layers {
|
||||
match layer.metadata(path) {
|
||||
Ok(metadata) => return Ok(metadata),
|
||||
Err(VfsError::NotFound(_)) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
for layer in &self.layers {
|
||||
match layer.read(path) {
|
||||
Ok(bytes) => return Ok(bytes),
|
||||
Err(VfsError::NotFound(_)) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
let mut by_key = BTreeMap::new();
|
||||
for layer in &self.layers {
|
||||
match layer.list(prefix) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let key = entry.path.as_str().to_ascii_uppercase();
|
||||
by_key.entry(key).or_insert(entry);
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(_)) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
let mut entries: Vec<_> = by_key.into_values().collect();
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_path::{normalize_relative, PathPolicy};
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
||||
let root = unique_test_dir("casefold");
|
||||
let dir = root.join("data").join("MAPS").join("Tut_1");
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
std::fs::write(dir.join("Land.msh"), b"mesh").expect("write");
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy)
|
||||
.expect("path");
|
||||
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh");
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_lists_files_below_prefix() {
|
||||
let root = unique_test_dir("list");
|
||||
std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir");
|
||||
std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write");
|
||||
std::fs::write(root.join("BuildDat.lst"), b"build").expect("write");
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let entries = vfs.list(&prefix).expect("list");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0]
|
||||
.path
|
||||
.as_str()
|
||||
.eq_ignore_ascii_case("DATA/MAPS/Land.map"));
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casefold_selector_reports_ambiguous_segments() {
|
||||
let err = select_casefolded_match(
|
||||
"data/file.bin",
|
||||
Path::new("/game"),
|
||||
"data",
|
||||
vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")],
|
||||
)
|
||||
.expect_err("ambiguous path");
|
||||
|
||||
assert!(matches!(err, VfsError::Ambiguous(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_uses_exact_lookup() {
|
||||
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
let mut vfs = MemoryVfs::default();
|
||||
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
|
||||
|
||||
assert_eq!(vfs.metadata(&path).expect("metadata").len, 7);
|
||||
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload");
|
||||
|
||||
let other_case =
|
||||
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vfs_uses_first_matching_layer() {
|
||||
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let mut high = MemoryVfs::default();
|
||||
let mut low = MemoryVfs::default();
|
||||
high.insert(path.clone(), Arc::from(b"high".as_slice()));
|
||||
low.insert(path.clone(), Arc::from(b"low".as_slice()));
|
||||
|
||||
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
|
||||
|
||||
assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high");
|
||||
let entries = overlay.list(&prefix).expect("list");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].metadata.len, 4);
|
||||
}
|
||||
|
||||
fn unique_test_dir(name: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-world"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,840 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Object handle with generation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ObjectHandle {
|
||||
/// Generation.
|
||||
pub generation: u32,
|
||||
/// Slot.
|
||||
pub slot: u32,
|
||||
}
|
||||
|
||||
/// Original mission object id.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OriginalObjectId(pub u32);
|
||||
|
||||
/// Owner id.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OwnerId(pub u16);
|
||||
|
||||
/// Tick.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct Tick(pub u64);
|
||||
|
||||
/// State hash.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct StateHash(pub [u8; 32]);
|
||||
|
||||
/// World phase.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WorldPhase {
|
||||
/// Idle.
|
||||
Idle,
|
||||
/// Calculating.
|
||||
Calculating,
|
||||
/// Applying deferred operations.
|
||||
ApplyingDeferred,
|
||||
/// Publishing snapshot.
|
||||
PublishingSnapshot,
|
||||
}
|
||||
|
||||
/// Object draft.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ObjectDraft {
|
||||
/// Original id.
|
||||
pub original_id: Option<OriginalObjectId>,
|
||||
}
|
||||
|
||||
/// Distinct object identity metadata.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct IdentityMetadata {
|
||||
/// Original mission object id.
|
||||
pub original_id: Option<OriginalObjectId>,
|
||||
/// Mirrored original id.
|
||||
pub mirror_id: Option<OriginalObjectId>,
|
||||
/// Local owner id.
|
||||
pub owner_id: Option<OwnerId>,
|
||||
}
|
||||
|
||||
/// World command.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorldCommand {
|
||||
/// Sequence.
|
||||
pub sequence: u64,
|
||||
/// Target.
|
||||
pub target: Option<ObjectHandle>,
|
||||
}
|
||||
|
||||
/// World event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorldEvent {
|
||||
/// Sequence.
|
||||
pub sequence: u64,
|
||||
/// Target object, if any.
|
||||
pub target: Option<ObjectHandle>,
|
||||
}
|
||||
|
||||
/// Input snapshot.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct InputSnapshot;
|
||||
|
||||
/// World snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorldSnapshot {
|
||||
/// Tick.
|
||||
pub tick: Tick,
|
||||
/// Live object handles.
|
||||
pub objects: Vec<ObjectHandle>,
|
||||
/// Commands processed during this step.
|
||||
pub events: Vec<WorldEvent>,
|
||||
/// State hash.
|
||||
pub hash: StateHash,
|
||||
}
|
||||
|
||||
/// World configuration.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct WorldConfig;
|
||||
|
||||
/// Fixed-step clock state.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FixedStepClock {
|
||||
accumulated_millis: u64,
|
||||
tick: Tick,
|
||||
paused: bool,
|
||||
platform_event_collections: u64,
|
||||
}
|
||||
|
||||
/// Fixed-step configuration.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct FixedStepConfig {
|
||||
/// Milliseconds per simulation tick.
|
||||
pub step_millis: u32,
|
||||
}
|
||||
|
||||
impl Default for FixedStepConfig {
|
||||
fn default() -> Self {
|
||||
Self { step_millis: 16 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown ordering report.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ShutdownReport {
|
||||
/// Object handles released before managers.
|
||||
pub released_objects: Vec<ObjectHandle>,
|
||||
/// Whether managers were released after objects.
|
||||
pub managers_released: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Slot {
|
||||
generation: u32,
|
||||
live: bool,
|
||||
registered: bool,
|
||||
original_id: Option<OriginalObjectId>,
|
||||
owner_id: Option<OwnerId>,
|
||||
mirror_id: Option<OriginalObjectId>,
|
||||
registration_sequence: Option<u64>,
|
||||
}
|
||||
|
||||
/// World.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct World {
|
||||
slots: Vec<Slot>,
|
||||
queue: VecDeque<WorldCommand>,
|
||||
deferred_delete: Vec<ObjectHandle>,
|
||||
phase: WorldPhase,
|
||||
tick: Tick,
|
||||
next_sequence: u64,
|
||||
next_registration_sequence: u64,
|
||||
}
|
||||
|
||||
/// World error.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum WorldError {
|
||||
/// Invalid handle.
|
||||
InvalidHandle,
|
||||
/// Stale handle.
|
||||
StaleHandle,
|
||||
/// Object already deleted.
|
||||
Deleted,
|
||||
/// Duplicate original object id.
|
||||
DuplicateOriginalObjectId(OriginalObjectId),
|
||||
/// Invalid fixed-step configuration.
|
||||
InvalidFixedStep,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorldError {}
|
||||
|
||||
/// Creates a world.
|
||||
#[must_use]
|
||||
pub fn new(_config: WorldConfig) -> World {
|
||||
World {
|
||||
slots: Vec::new(),
|
||||
queue: VecDeque::new(),
|
||||
deferred_delete: Vec::new(),
|
||||
phase: WorldPhase::Idle,
|
||||
tick: Tick(0),
|
||||
next_sequence: 0,
|
||||
next_registration_sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs an object without registering it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError::InvalidHandle`] if the slot index cannot be
|
||||
/// represented by an [`ObjectHandle`].
|
||||
pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectHandle, WorldError> {
|
||||
let slot = u32::try_from(world.slots.len()).map_err(|_| WorldError::InvalidHandle)?;
|
||||
let handle = ObjectHandle {
|
||||
generation: 1,
|
||||
slot,
|
||||
};
|
||||
world.slots.push(Slot {
|
||||
generation: 1,
|
||||
live: true,
|
||||
registered: false,
|
||||
original_id: draft.original_id,
|
||||
owner_id: None,
|
||||
mirror_id: None,
|
||||
registration_sequence: None,
|
||||
});
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Registers a constructed object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn register_object(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
|
||||
let original_id = checked_slot(world, handle)?.original_id;
|
||||
if let Some(original_id) = original_id {
|
||||
let duplicate = world.slots.iter().enumerate().any(|(idx, slot)| {
|
||||
u32::try_from(idx).is_ok_and(|slot_index| slot_index != handle.slot)
|
||||
&& slot.live
|
||||
&& slot.registered
|
||||
&& slot.original_id == Some(original_id)
|
||||
});
|
||||
if duplicate {
|
||||
return Err(WorldError::DuplicateOriginalObjectId(original_id));
|
||||
}
|
||||
}
|
||||
let sequence = world.next_registration_sequence;
|
||||
world.next_registration_sequence = world.next_registration_sequence.saturating_add(1);
|
||||
let slot = checked_slot_mut(world, handle)?;
|
||||
slot.registered = true;
|
||||
slot.registration_sequence = Some(sequence);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attaches local ownership metadata to an object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn set_owner(
|
||||
world: &mut World,
|
||||
handle: ObjectHandle,
|
||||
owner_id: Option<OwnerId>,
|
||||
) -> Result<(), WorldError> {
|
||||
checked_slot_mut(world, handle)?.owner_id = owner_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attaches mirror metadata to an object without changing its original id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn set_mirror_original(
|
||||
world: &mut World,
|
||||
handle: ObjectHandle,
|
||||
mirror_id: Option<OriginalObjectId>,
|
||||
) -> Result<(), WorldError> {
|
||||
checked_slot_mut(world, handle)?.mirror_id = mirror_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns registration sequence for a live object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn registration_sequence(
|
||||
world: &World,
|
||||
handle: ObjectHandle,
|
||||
) -> Result<Option<u64>, WorldError> {
|
||||
Ok(checked_slot(world, handle)?.registration_sequence)
|
||||
}
|
||||
|
||||
/// Returns object identity metadata.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn identity_metadata(
|
||||
world: &World,
|
||||
handle: ObjectHandle,
|
||||
) -> Result<IdentityMetadata, WorldError> {
|
||||
let slot = checked_slot(world, handle)?;
|
||||
Ok(IdentityMetadata {
|
||||
original_id: slot.original_id,
|
||||
mirror_id: slot.mirror_id,
|
||||
owner_id: slot.owner_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests deletion.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn request_delete(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
|
||||
checked_slot(world, handle)?;
|
||||
if world.phase == WorldPhase::Calculating {
|
||||
if !world.deferred_delete.contains(&handle) {
|
||||
world.deferred_delete.push(handle);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
delete_now(world, handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues a command.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] when a targeted command references an invalid
|
||||
/// handle.
|
||||
pub fn enqueue(world: &mut World, mut command: WorldCommand) -> Result<(), WorldError> {
|
||||
if let Some(handle) = command.target {
|
||||
checked_slot(world, handle)?;
|
||||
}
|
||||
command.sequence = world.next_sequence;
|
||||
world.next_sequence = world.next_sequence.saturating_add(1);
|
||||
world.queue.push_back(command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advances one deterministic step.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if a queued command references a stale, deleted, or
|
||||
/// out-of-range handle.
|
||||
pub fn step(world: &mut World, input: &InputSnapshot) -> Result<WorldSnapshot, WorldError> {
|
||||
step_with_handler(world, input, |_, _| Ok(()))
|
||||
}
|
||||
|
||||
/// Advances one deterministic step with a command callback.
|
||||
///
|
||||
/// The callback runs while the world is in the calculating phase, which allows
|
||||
/// tests and adapters to exercise deferred deletion semantics without exposing
|
||||
/// mutable slot internals.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if a queued command references a stale, deleted, or
|
||||
/// out-of-range handle, or if the callback reports a world error.
|
||||
pub fn step_with_handler<F>(
|
||||
world: &mut World,
|
||||
_input: &InputSnapshot,
|
||||
mut handler: F,
|
||||
) -> Result<WorldSnapshot, WorldError>
|
||||
where
|
||||
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
|
||||
{
|
||||
world.phase = WorldPhase::Calculating;
|
||||
let mut events = Vec::new();
|
||||
while let Some(command) = world.queue.pop_front() {
|
||||
if let Some(handle) = command.target {
|
||||
if world.deferred_delete.contains(&handle) {
|
||||
continue;
|
||||
}
|
||||
checked_slot(world, handle)?;
|
||||
}
|
||||
handler(world, &command)?;
|
||||
events.push(WorldEvent {
|
||||
sequence: command.sequence,
|
||||
target: command.target,
|
||||
});
|
||||
}
|
||||
world.phase = WorldPhase::ApplyingDeferred;
|
||||
let deletes = std::mem::take(&mut world.deferred_delete);
|
||||
for handle in deletes {
|
||||
let _ = delete_now(world, handle);
|
||||
}
|
||||
world.tick.0 = world.tick.0.saturating_add(1);
|
||||
world.phase = WorldPhase::PublishingSnapshot;
|
||||
let snapshot = WorldSnapshot {
|
||||
tick: world.tick,
|
||||
objects: live_registered(world),
|
||||
events,
|
||||
hash: canonical_state_hash(world),
|
||||
};
|
||||
world.phase = WorldPhase::Idle;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Computes canonical state hash.
|
||||
#[must_use]
|
||||
pub fn canonical_state_hash(world: &World) -> StateHash {
|
||||
let mut state = 0xcbf2_9ce4_8422_2325_u64;
|
||||
hash_u64(&mut state, world.tick.0);
|
||||
for (idx, slot) in world.slots.iter().enumerate() {
|
||||
hash_u64(&mut state, idx as u64);
|
||||
hash_u64(&mut state, u64::from(slot.generation));
|
||||
hash_u64(&mut state, u64::from(u8::from(slot.live)));
|
||||
hash_u64(&mut state, u64::from(u8::from(slot.registered)));
|
||||
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
|
||||
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
|
||||
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
|
||||
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
|
||||
}
|
||||
let mut out = [0; 32];
|
||||
out[..8].copy_from_slice(&state.to_le_bytes());
|
||||
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
|
||||
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
|
||||
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
|
||||
StateHash(out)
|
||||
}
|
||||
|
||||
/// Creates a fixed-step clock.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
|
||||
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
|
||||
if config.step_millis == 0 {
|
||||
return Err(WorldError::InvalidFixedStep);
|
||||
}
|
||||
Ok(FixedStepClock {
|
||||
accumulated_millis: 0,
|
||||
tick: Tick(0),
|
||||
paused: false,
|
||||
platform_event_collections: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Records platform event collection independently of game time.
|
||||
pub fn collect_platform_events(clock: &mut FixedStepClock) {
|
||||
clock.platform_event_collections = clock.platform_event_collections.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Sets pause state.
|
||||
pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
|
||||
clock.paused = paused;
|
||||
}
|
||||
|
||||
/// Advances fixed-step game time.
|
||||
///
|
||||
/// Returns the number of simulation ticks that should be executed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
|
||||
pub fn advance_fixed_step(
|
||||
clock: &mut FixedStepClock,
|
||||
config: FixedStepConfig,
|
||||
elapsed_millis: u64,
|
||||
) -> Result<u32, WorldError> {
|
||||
if config.step_millis == 0 {
|
||||
return Err(WorldError::InvalidFixedStep);
|
||||
}
|
||||
if clock.paused {
|
||||
return Ok(0);
|
||||
}
|
||||
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
|
||||
let step = u64::from(config.step_millis);
|
||||
let mut ticks = 0_u32;
|
||||
while clock.accumulated_millis >= step {
|
||||
clock.accumulated_millis -= step;
|
||||
clock.tick.0 = clock.tick.0.saturating_add(1);
|
||||
ticks = ticks.saturating_add(1);
|
||||
}
|
||||
Ok(ticks)
|
||||
}
|
||||
|
||||
/// Returns fixed-step clock tick.
|
||||
#[must_use]
|
||||
pub fn fixed_step_tick(clock: &FixedStepClock) -> Tick {
|
||||
clock.tick
|
||||
}
|
||||
|
||||
/// Returns platform event collection count.
|
||||
#[must_use]
|
||||
pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
|
||||
clock.platform_event_collections
|
||||
}
|
||||
|
||||
/// Runs end-frame callbacks in stable sequence order.
|
||||
#[must_use]
|
||||
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
|
||||
callbacks.sort_by_key(|event| event.sequence);
|
||||
callbacks.into_iter().map(|event| event.sequence).collect()
|
||||
}
|
||||
|
||||
/// Releases live objects before managers.
|
||||
#[must_use]
|
||||
pub fn shutdown(mut world: World) -> ShutdownReport {
|
||||
let released_objects = live_registered(&world);
|
||||
for slot in &mut world.slots {
|
||||
slot.live = false;
|
||||
slot.registered = false;
|
||||
slot.generation = slot.generation.saturating_add(1);
|
||||
}
|
||||
ShutdownReport {
|
||||
released_objects,
|
||||
managers_released: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_u64(state: &mut u64, value: u64) {
|
||||
for byte in value.to_le_bytes() {
|
||||
*state ^= u64::from(byte);
|
||||
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
||||
let slot = world
|
||||
.slots
|
||||
.get(handle.slot as usize)
|
||||
.ok_or(WorldError::InvalidHandle)?;
|
||||
if slot.generation != handle.generation {
|
||||
return Err(WorldError::StaleHandle);
|
||||
}
|
||||
if !slot.live {
|
||||
return Err(WorldError::Deleted);
|
||||
}
|
||||
Ok(slot)
|
||||
}
|
||||
|
||||
fn checked_slot_mut(world: &mut World, handle: ObjectHandle) -> Result<&mut Slot, WorldError> {
|
||||
let slot = world
|
||||
.slots
|
||||
.get_mut(handle.slot as usize)
|
||||
.ok_or(WorldError::InvalidHandle)?;
|
||||
if slot.generation != handle.generation {
|
||||
return Err(WorldError::StaleHandle);
|
||||
}
|
||||
if !slot.live {
|
||||
return Err(WorldError::Deleted);
|
||||
}
|
||||
Ok(slot)
|
||||
}
|
||||
|
||||
fn delete_now(world: &mut World, handle: ObjectHandle) -> Result<(), WorldError> {
|
||||
let slot = checked_slot_mut(world, handle)?;
|
||||
slot.live = false;
|
||||
slot.generation = slot.generation.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn live_registered(world: &World) -> Vec<ObjectHandle> {
|
||||
world
|
||||
.slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, slot)| {
|
||||
let slot_index = u32::try_from(idx).ok()?;
|
||||
(slot.live && slot.registered).then_some(ObjectHandle {
|
||||
generation: slot.generation,
|
||||
slot: slot_index,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn construct_register_and_hash_are_stable() {
|
||||
let mut world = new(WorldConfig);
|
||||
let handle = construct_object(&mut world, ObjectDraft { original_id: None }).expect("obj");
|
||||
let before = step(&mut world, &InputSnapshot).expect("step");
|
||||
assert!(before.objects.is_empty());
|
||||
register_object(&mut world, handle).expect("register");
|
||||
let after = step(&mut world, &InputSnapshot).expect("step");
|
||||
assert_eq!(after.objects, vec![handle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_sequence_stale_and_duplicate_original_contracts() {
|
||||
let mut world = new(WorldConfig);
|
||||
let first = construct_object(
|
||||
&mut world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(7)),
|
||||
},
|
||||
)
|
||||
.expect("first");
|
||||
let second = construct_object(
|
||||
&mut world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(8)),
|
||||
},
|
||||
)
|
||||
.expect("second");
|
||||
register_object(&mut world, first).expect("register first");
|
||||
register_object(&mut world, second).expect("register second");
|
||||
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
||||
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
||||
|
||||
request_delete(&mut world, first).expect("delete");
|
||||
assert_eq!(
|
||||
register_object(&mut world, first),
|
||||
Err(WorldError::StaleHandle)
|
||||
);
|
||||
let recycled = ObjectHandle {
|
||||
generation: first.generation,
|
||||
slot: first.slot,
|
||||
};
|
||||
assert_eq!(
|
||||
register_object(&mut world, recycled),
|
||||
Err(WorldError::StaleHandle)
|
||||
);
|
||||
|
||||
let duplicate = construct_object(
|
||||
&mut world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(8)),
|
||||
},
|
||||
)
|
||||
.expect("duplicate");
|
||||
assert_eq!(
|
||||
register_object(&mut world, duplicate),
|
||||
Err(WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
|
||||
let mut world = new(WorldConfig);
|
||||
let handle = construct_object(
|
||||
&mut world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(10)),
|
||||
},
|
||||
)
|
||||
.expect("object");
|
||||
set_mirror_original(&mut world, handle, Some(OriginalObjectId(20))).expect("mirror");
|
||||
set_owner(&mut world, handle, Some(OwnerId(3))).expect("owner");
|
||||
assert_eq!(
|
||||
identity_metadata(&world, handle),
|
||||
Ok(IdentityMetadata {
|
||||
original_id: Some(OriginalObjectId(10)),
|
||||
mirror_id: Some(OriginalObjectId(20)),
|
||||
owner_id: Some(OwnerId(3))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_fifo_and_deferred_delete_during_calculation() {
|
||||
let mut world = new(WorldConfig);
|
||||
let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first");
|
||||
let second =
|
||||
construct_object(&mut world, ObjectDraft { original_id: None }).expect("second");
|
||||
register_object(&mut world, first).expect("register first");
|
||||
register_object(&mut world, second).expect("register second");
|
||||
enqueue(
|
||||
&mut world,
|
||||
WorldCommand {
|
||||
sequence: 99,
|
||||
target: Some(first),
|
||||
},
|
||||
)
|
||||
.expect("enqueue first");
|
||||
enqueue(
|
||||
&mut world,
|
||||
WorldCommand {
|
||||
sequence: 99,
|
||||
target: Some(second),
|
||||
},
|
||||
)
|
||||
.expect("enqueue second");
|
||||
enqueue(
|
||||
&mut world,
|
||||
WorldCommand {
|
||||
sequence: 99,
|
||||
target: Some(first),
|
||||
},
|
||||
)
|
||||
.expect("enqueue first again");
|
||||
|
||||
let snapshot = step_with_handler(&mut world, &InputSnapshot, |world, command| {
|
||||
if command.target == Some(first) {
|
||||
request_delete(world, first)?;
|
||||
request_delete(world, first)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect("step");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.events,
|
||||
vec![
|
||||
WorldEvent {
|
||||
sequence: 0,
|
||||
target: Some(first)
|
||||
},
|
||||
WorldEvent {
|
||||
sequence: 1,
|
||||
target: Some(second)
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_delete(&mut world, first),
|
||||
Err(WorldError::StaleHandle)
|
||||
);
|
||||
assert_eq!(
|
||||
step(&mut world, &InputSnapshot).expect("step").objects,
|
||||
vec![second]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_hash_determinism_and_immutability() {
|
||||
let mut left = new(WorldConfig);
|
||||
let mut right = new(WorldConfig);
|
||||
for world in [&mut left, &mut right] {
|
||||
let handle = construct_object(
|
||||
world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(1)),
|
||||
},
|
||||
)
|
||||
.expect("object");
|
||||
register_object(world, handle).expect("register");
|
||||
}
|
||||
let snapshot = step(&mut left, &InputSnapshot).expect("snapshot");
|
||||
let clone = snapshot.clone();
|
||||
let extra = construct_object(&mut left, ObjectDraft { original_id: None }).expect("extra");
|
||||
register_object(&mut left, extra).expect("register extra");
|
||||
|
||||
assert_eq!(snapshot, clone);
|
||||
assert_eq!(
|
||||
clone.hash,
|
||||
step(&mut right, &InputSnapshot).expect("right").hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_step_pause_and_long_determinism_are_stable() {
|
||||
let config = FixedStepConfig { step_millis: 20 };
|
||||
let mut clock = fixed_step_clock(config).expect("clock");
|
||||
collect_platform_events(&mut clock);
|
||||
set_paused(&mut clock, true);
|
||||
assert_eq!(advance_fixed_step(&mut clock, config, 100), Ok(0));
|
||||
collect_platform_events(&mut clock);
|
||||
assert_eq!(fixed_step_tick(&clock), Tick(0));
|
||||
assert_eq!(platform_event_collections(&clock), 2);
|
||||
|
||||
set_paused(&mut clock, false);
|
||||
assert_eq!(advance_fixed_step(&mut clock, config, 45), Ok(2));
|
||||
assert_eq!(fixed_step_tick(&clock), Tick(2));
|
||||
|
||||
let mut first = new(WorldConfig);
|
||||
let mut second = new(WorldConfig);
|
||||
let mut first_hashes = Vec::new();
|
||||
let mut second_hashes = Vec::new();
|
||||
for _ in 0..10_000 {
|
||||
first_hashes.push(step(&mut first, &InputSnapshot).expect("first").hash);
|
||||
second_hashes.push(step(&mut second, &InputSnapshot).expect("second").hash);
|
||||
}
|
||||
assert_eq!(first_hashes, second_hashes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
|
||||
let callbacks = vec![
|
||||
WorldEvent {
|
||||
sequence: 3,
|
||||
target: None,
|
||||
},
|
||||
WorldEvent {
|
||||
sequence: 1,
|
||||
target: None,
|
||||
},
|
||||
WorldEvent {
|
||||
sequence: 2,
|
||||
target: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(end_frame_callback_order(callbacks), vec![1, 2, 3]);
|
||||
|
||||
let mut rendered = new(WorldConfig);
|
||||
let mut headless = rendered.clone();
|
||||
assert_eq!(
|
||||
step(&mut rendered, &InputSnapshot).expect("rendered").hash,
|
||||
step(&mut headless, &InputSnapshot).expect("headless").hash
|
||||
);
|
||||
|
||||
let handle =
|
||||
construct_object(&mut rendered, ObjectDraft { original_id: None }).expect("object");
|
||||
register_object(&mut rendered, handle).expect("register");
|
||||
assert_eq!(
|
||||
shutdown(rendered),
|
||||
ShutdownReport {
|
||||
released_objects: vec![handle],
|
||||
managers_released: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_command_delete_sequences_preserve_registry_invariants() {
|
||||
for seed in 0_u32..64 {
|
||||
let mut world = new(WorldConfig);
|
||||
let mut handles = Vec::new();
|
||||
for index in 0..8 {
|
||||
let handle = construct_object(
|
||||
&mut world,
|
||||
ObjectDraft {
|
||||
original_id: Some(OriginalObjectId(seed * 100 + index)),
|
||||
},
|
||||
)
|
||||
.expect("object");
|
||||
register_object(&mut world, handle).expect("register");
|
||||
handles.push(handle);
|
||||
}
|
||||
for (index, handle) in handles.iter().copied().enumerate() {
|
||||
if (seed as usize + index) % 3 == 0 {
|
||||
request_delete(&mut world, handle).expect("delete");
|
||||
} else {
|
||||
enqueue(
|
||||
&mut world,
|
||||
WorldCommand {
|
||||
sequence: 0,
|
||||
target: Some(handle),
|
||||
},
|
||||
)
|
||||
.expect("enqueue");
|
||||
}
|
||||
}
|
||||
let snapshot = step(&mut world, &InputSnapshot).expect("step");
|
||||
for handle in snapshot.objects {
|
||||
assert!(registration_sequence(&world, handle)
|
||||
.expect("sequence")
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "msh-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
nres = { path = "../nres" }
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
proptest = "1"
|
||||
@@ -1,14 +0,0 @@
|
||||
# msh-core
|
||||
|
||||
Парсер core-части формата `MSH`.
|
||||
|
||||
Покрывает:
|
||||
|
||||
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
|
||||
- `Res4`, `Res5`, `Res10` (опциональные);
|
||||
- slot lookup по `node/lod/group`.
|
||||
|
||||
Тесты:
|
||||
|
||||
- прогон по всем `.msh` в `testdata`;
|
||||
- синтетическая минимальная модель.
|
||||
@@ -1,75 +0,0 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Nres(nres::error::Error),
|
||||
MissingResource {
|
||||
kind: u32,
|
||||
label: &'static str,
|
||||
},
|
||||
InvalidResourceSize {
|
||||
label: &'static str,
|
||||
size: usize,
|
||||
stride: usize,
|
||||
},
|
||||
InvalidRes2Size {
|
||||
size: usize,
|
||||
},
|
||||
UnsupportedNodeStride {
|
||||
stride: usize,
|
||||
},
|
||||
IndexOutOfBounds {
|
||||
label: &'static str,
|
||||
index: usize,
|
||||
limit: usize,
|
||||
},
|
||||
IntegerOverflow,
|
||||
}
|
||||
|
||||
impl From<nres::error::Error> for Error {
|
||||
fn from(value: nres::error::Error) -> Self {
|
||||
Self::Nres(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Nres(err) => write!(f, "{err}"),
|
||||
Self::MissingResource { kind, label } => {
|
||||
write!(f, "missing required resource type={kind} ({label})")
|
||||
}
|
||||
Self::InvalidResourceSize {
|
||||
label,
|
||||
size,
|
||||
stride,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"invalid {label} size={size}, expected multiple of stride={stride}"
|
||||
)
|
||||
}
|
||||
Self::InvalidRes2Size { size } => {
|
||||
write!(f, "invalid Res2 size={size}, expected >= 140")
|
||||
}
|
||||
Self::UnsupportedNodeStride { stride } => {
|
||||
write!(
|
||||
f,
|
||||
"unsupported Res1 node stride={stride}, expected 38 or 24"
|
||||
)
|
||||
}
|
||||
Self::IndexOutOfBounds {
|
||||
label,
|
||||
index,
|
||||
limit,
|
||||
} => write!(
|
||||
f,
|
||||
"{label} index out of bounds: index={index}, limit={limit}"
|
||||
),
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
@@ -1,434 +0,0 @@
|
||||
pub mod error;
|
||||
|
||||
use crate::error::Error;
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
pub const RES1_NODE_TABLE: u32 = 1;
|
||||
pub const RES2_SLOTS: u32 = 2;
|
||||
pub const RES3_POSITIONS: u32 = 3;
|
||||
pub const RES4_NORMALS: u32 = 4;
|
||||
pub const RES5_UV0: u32 = 5;
|
||||
pub const RES6_INDICES: u32 = 6;
|
||||
pub const RES10_NAMES: u32 = 10;
|
||||
pub const RES13_BATCHES: u32 = 13;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Slot {
|
||||
pub tri_start: u16,
|
||||
pub tri_count: u16,
|
||||
pub batch_start: u16,
|
||||
pub batch_count: u16,
|
||||
pub aabb_min: [f32; 3],
|
||||
pub aabb_max: [f32; 3],
|
||||
pub sphere_center: [f32; 3],
|
||||
pub sphere_radius: f32,
|
||||
pub opaque: [u32; 5],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Batch {
|
||||
pub batch_flags: u16,
|
||||
pub material_index: u16,
|
||||
pub opaque4: u16,
|
||||
pub opaque6: u16,
|
||||
pub index_count: u16,
|
||||
pub index_start: u32,
|
||||
pub opaque14: u16,
|
||||
pub base_vertex: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Model {
|
||||
pub node_stride: usize,
|
||||
pub node_count: usize,
|
||||
pub nodes_raw: Vec<u8>,
|
||||
pub slots: Vec<Slot>,
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
pub normals: Option<Vec<[i8; 4]>>,
|
||||
pub uv0: Option<Vec<[i16; 2]>>,
|
||||
pub indices: Vec<u16>,
|
||||
pub batches: Vec<Batch>,
|
||||
pub node_names: Option<Vec<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> {
|
||||
if node_index >= self.node_count || lod >= 3 || group >= 5 {
|
||||
return None;
|
||||
}
|
||||
if self.node_stride != 38 {
|
||||
return None;
|
||||
}
|
||||
let node_off = node_index.checked_mul(self.node_stride)?;
|
||||
let matrix_off = node_off.checked_add(8)?;
|
||||
let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?;
|
||||
let raw = read_u16(&self.nodes_raw, word_off).ok()?;
|
||||
if raw == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
let idx = usize::from(raw);
|
||||
if idx >= self.slots.len() {
|
||||
return None;
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
|
||||
let archive = nres::Archive::open_bytes(
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
nres::OpenOptions::default(),
|
||||
)?;
|
||||
|
||||
let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?;
|
||||
let res2 = read_required(&archive, RES2_SLOTS, "Res2")?;
|
||||
let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?;
|
||||
let res6 = read_required(&archive, RES6_INDICES, "Res6")?;
|
||||
let res13 = read_required(&archive, RES13_BATCHES, "Res13")?;
|
||||
|
||||
let res4 = read_optional(&archive, RES4_NORMALS)?;
|
||||
let res5 = read_optional(&archive, RES5_UV0)?;
|
||||
let res10 = read_optional(&archive, RES10_NAMES)?;
|
||||
|
||||
let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?;
|
||||
if node_stride != 38 && node_stride != 24 {
|
||||
return Err(Error::UnsupportedNodeStride {
|
||||
stride: node_stride,
|
||||
});
|
||||
}
|
||||
if res1.bytes.len() % node_stride != 0 {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label: "Res1",
|
||||
size: res1.bytes.len(),
|
||||
stride: node_stride,
|
||||
});
|
||||
}
|
||||
let node_count = res1.bytes.len() / node_stride;
|
||||
|
||||
if res2.bytes.len() < 0x8C {
|
||||
return Err(Error::InvalidRes2Size {
|
||||
size: res2.bytes.len(),
|
||||
});
|
||||
}
|
||||
let slot_blob = res2
|
||||
.bytes
|
||||
.len()
|
||||
.checked_sub(0x8C)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if slot_blob % 68 != 0 {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label: "Res2.slots",
|
||||
size: slot_blob,
|
||||
stride: 68,
|
||||
});
|
||||
}
|
||||
let slot_count = slot_blob / 68;
|
||||
let mut slots = Vec::with_capacity(slot_count);
|
||||
for i in 0..slot_count {
|
||||
let off = 0x8Cusize
|
||||
.checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
slots.push(Slot {
|
||||
tri_start: read_u16(&res2.bytes, off)?,
|
||||
tri_count: read_u16(&res2.bytes, off + 2)?,
|
||||
batch_start: read_u16(&res2.bytes, off + 4)?,
|
||||
batch_count: read_u16(&res2.bytes, off + 6)?,
|
||||
aabb_min: [
|
||||
read_f32(&res2.bytes, off + 8)?,
|
||||
read_f32(&res2.bytes, off + 12)?,
|
||||
read_f32(&res2.bytes, off + 16)?,
|
||||
],
|
||||
aabb_max: [
|
||||
read_f32(&res2.bytes, off + 20)?,
|
||||
read_f32(&res2.bytes, off + 24)?,
|
||||
read_f32(&res2.bytes, off + 28)?,
|
||||
],
|
||||
sphere_center: [
|
||||
read_f32(&res2.bytes, off + 32)?,
|
||||
read_f32(&res2.bytes, off + 36)?,
|
||||
read_f32(&res2.bytes, off + 40)?,
|
||||
],
|
||||
sphere_radius: read_f32(&res2.bytes, off + 44)?,
|
||||
opaque: [
|
||||
read_u32(&res2.bytes, off + 48)?,
|
||||
read_u32(&res2.bytes, off + 52)?,
|
||||
read_u32(&res2.bytes, off + 56)?,
|
||||
read_u32(&res2.bytes, off + 60)?,
|
||||
read_u32(&res2.bytes, off + 64)?,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let positions = parse_positions(&res3.bytes)?;
|
||||
let indices = parse_u16_array(&res6.bytes, "Res6")?;
|
||||
let batches = parse_batches(&res13.bytes)?;
|
||||
validate_slot_batch_ranges(&slots, batches.len())?;
|
||||
validate_batch_index_ranges(&batches, indices.len())?;
|
||||
|
||||
let normals = match res4 {
|
||||
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
|
||||
None => None,
|
||||
};
|
||||
let uv0 = match res5 {
|
||||
Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?),
|
||||
None => None,
|
||||
};
|
||||
let node_names = match res10 {
|
||||
Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Model {
|
||||
node_stride,
|
||||
node_count,
|
||||
nodes_raw: res1.bytes,
|
||||
slots,
|
||||
positions,
|
||||
normals,
|
||||
uv0,
|
||||
indices,
|
||||
batches,
|
||||
node_names,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
|
||||
for slot in slots {
|
||||
let start = usize::from(slot.batch_start);
|
||||
let end = start
|
||||
.checked_add(usize::from(slot.batch_count))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if end > batch_count {
|
||||
return Err(Error::IndexOutOfBounds {
|
||||
label: "Res2.batch_range",
|
||||
index: end,
|
||||
limit: batch_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
|
||||
for batch in batches {
|
||||
let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
|
||||
let end = start
|
||||
.checked_add(usize::from(batch.index_count))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if end > index_count {
|
||||
return Err(Error::IndexOutOfBounds {
|
||||
label: "Res13.index_range",
|
||||
index: end,
|
||||
limit: index_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
|
||||
if !data.len().is_multiple_of(12) {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label: "Res3",
|
||||
size: data.len(),
|
||||
stride: 12,
|
||||
});
|
||||
}
|
||||
let count = data.len() / 12;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let off = i * 12;
|
||||
out.push([
|
||||
read_f32(data, off)?,
|
||||
read_f32(data, off + 4)?,
|
||||
read_f32(data, off + 8)?,
|
||||
]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> {
|
||||
if !data.len().is_multiple_of(20) {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label: "Res13",
|
||||
size: data.len(),
|
||||
stride: 20,
|
||||
});
|
||||
}
|
||||
let count = data.len() / 20;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let off = i * 20;
|
||||
out.push(Batch {
|
||||
batch_flags: read_u16(data, off)?,
|
||||
material_index: read_u16(data, off + 2)?,
|
||||
opaque4: read_u16(data, off + 4)?,
|
||||
opaque6: read_u16(data, off + 6)?,
|
||||
index_count: read_u16(data, off + 8)?,
|
||||
index_start: read_u32(data, off + 10)?,
|
||||
opaque14: read_u16(data, off + 14)?,
|
||||
base_vertex: read_u32(data, off + 16)?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> {
|
||||
if !data.len().is_multiple_of(2) {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label,
|
||||
size: data.len(),
|
||||
stride: 2,
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() / 2);
|
||||
for i in (0..data.len()).step_by(2) {
|
||||
out.push(read_u16(data, i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> {
|
||||
if !data.len().is_multiple_of(4) {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label,
|
||||
size: data.len(),
|
||||
stride: 4,
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() / 4);
|
||||
for i in (0..data.len()).step_by(4) {
|
||||
out.push([
|
||||
read_i8(data, i)?,
|
||||
read_i8(data, i + 1)?,
|
||||
read_i8(data, i + 2)?,
|
||||
read_i8(data, i + 3)?,
|
||||
]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> {
|
||||
if !data.len().is_multiple_of(4) {
|
||||
return Err(Error::InvalidResourceSize {
|
||||
label,
|
||||
size: data.len(),
|
||||
stride: 4,
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() / 4);
|
||||
for i in (0..data.len()).step_by(4) {
|
||||
out.push([read_i16(data, i)?, read_i16(data, i + 2)?]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> {
|
||||
let mut out = Vec::with_capacity(node_count);
|
||||
let mut off = 0usize;
|
||||
for _ in 0..node_count {
|
||||
let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?;
|
||||
off = off.checked_add(4).ok_or(Error::IntegerOverflow)?;
|
||||
if len == 0 {
|
||||
out.push(None);
|
||||
continue;
|
||||
}
|
||||
let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?;
|
||||
let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?;
|
||||
let slice = data.get(off..end).ok_or(Error::InvalidResourceSize {
|
||||
label: "Res10",
|
||||
size: data.len(),
|
||||
stride: 1,
|
||||
})?;
|
||||
let text = if slice.last().copied() == Some(0) {
|
||||
&slice[..slice.len().saturating_sub(1)]
|
||||
} else {
|
||||
slice
|
||||
};
|
||||
let decoded = decode_cp1251(text);
|
||||
out.push(Some(decoded));
|
||||
off = end;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
|
||||
struct RawResource {
|
||||
meta: nres::EntryMeta,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> {
|
||||
let id = archive
|
||||
.entries()
|
||||
.find(|entry| entry.meta.kind == kind)
|
||||
.map(|entry| entry.id)
|
||||
.ok_or(Error::MissingResource { kind, label })?;
|
||||
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
|
||||
label,
|
||||
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
|
||||
limit: archive.entry_count(),
|
||||
})?;
|
||||
let data = archive.read(id)?.into_owned();
|
||||
Ok(RawResource {
|
||||
meta: entry.meta.clone(),
|
||||
bytes: data,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> {
|
||||
let Some(id) = archive
|
||||
.entries()
|
||||
.find(|entry| entry.meta.kind == kind)
|
||||
.map(|entry| entry.id)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
|
||||
label: "optional",
|
||||
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
|
||||
limit: archive.entry_count(),
|
||||
})?;
|
||||
let data = archive.read(id)?.into_owned();
|
||||
Ok(Some(RawResource {
|
||||
meta: entry.meta.clone(),
|
||||
bytes: data,
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(u16::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
|
||||
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(i16::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_i8(data: &[u8], offset: usize) -> Result<i8> {
|
||||
let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?;
|
||||
Ok(i8::from_le_bytes([byte]))
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_f32(data: &[u8], offset: usize) -> Result<f32> {
|
||||
Ok(f32::from_bits(read_u32(data, offset)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,438 +0,0 @@
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use nres::Archive;
|
||||
use proptest::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
fs::read(path)
|
||||
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_msh_name(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".msh")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SyntheticEntry {
|
||||
kind: u32,
|
||||
name: String,
|
||||
attr1: u32,
|
||||
attr2: u32,
|
||||
attr3: u32,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(b"NRes");
|
||||
payload.extend_from_slice(&0x100u32.to_le_bytes());
|
||||
payload.extend_from_slice(
|
||||
&u32::try_from(entries.len())
|
||||
.expect("entry count overflow in test")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
|
||||
|
||||
let mut resource_offsets = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
|
||||
payload.extend_from_slice(&entry.data);
|
||||
while !payload.len().is_multiple_of(8) {
|
||||
payload.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
payload.extend_from_slice(&entry.kind.to_le_bytes());
|
||||
payload.extend_from_slice(&entry.attr1.to_le_bytes());
|
||||
payload.extend_from_slice(&entry.attr2.to_le_bytes());
|
||||
payload.extend_from_slice(
|
||||
&u32::try_from(entry.data.len())
|
||||
.expect("size overflow in test")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
payload.extend_from_slice(&entry.attr3.to_le_bytes());
|
||||
|
||||
let mut name_raw = [0u8; 36];
|
||||
let name_bytes = entry.name.as_bytes();
|
||||
assert!(name_bytes.len() <= 35, "name too long for synthetic test");
|
||||
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||
payload.extend_from_slice(&name_raw);
|
||||
|
||||
payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
|
||||
payload.extend_from_slice(&(index as u32).to_le_bytes());
|
||||
}
|
||||
|
||||
let total_size = u32::try_from(payload.len()).expect("size overflow in test");
|
||||
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||
payload
|
||||
}
|
||||
|
||||
fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
|
||||
SyntheticEntry {
|
||||
kind,
|
||||
name: name.to_string(),
|
||||
attr1: 1,
|
||||
attr2: 0,
|
||||
attr3,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
|
||||
let mut out = vec![0u8; node_count.saturating_mul(38)];
|
||||
for node in 0..node_count {
|
||||
let node_off = node * 38;
|
||||
for i in 0..15 {
|
||||
let off = node_off + 8 + i * 2;
|
||||
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if let Some(slot) = node0_slot00 {
|
||||
out[8..10].copy_from_slice(&slot.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
|
||||
vec![0u8; node_count.saturating_mul(24)]
|
||||
}
|
||||
|
||||
fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
|
||||
let mut res2 = vec![0u8; 0x8C + 68];
|
||||
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
|
||||
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
|
||||
res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
|
||||
res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
|
||||
res2
|
||||
}
|
||||
|
||||
fn res3_triangle_positions() -> Vec<u8> {
|
||||
[0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn res4_normals() -> Vec<u8> {
|
||||
vec![127u8, 0u8, 128u8, 0u8]
|
||||
}
|
||||
|
||||
fn res5_uv0() -> Vec<u8> {
|
||||
[1024i16, -1024i16]
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn res6_triangle_indices() -> Vec<u8> {
|
||||
[0u16, 1u16, 2u16]
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
|
||||
let mut batch = vec![0u8; 20];
|
||||
batch[0..2].copy_from_slice(&0u16.to_le_bytes());
|
||||
batch[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||
batch[8..10].copy_from_slice(&index_count.to_le_bytes());
|
||||
batch[10..14].copy_from_slice(&index_start.to_le_bytes());
|
||||
batch[16..20].copy_from_slice(&0u32.to_le_bytes());
|
||||
batch
|
||||
}
|
||||
|
||||
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for name in names {
|
||||
match name {
|
||||
Some(name) => {
|
||||
out.extend_from_slice(
|
||||
&u32::try_from(name.len())
|
||||
.expect("name size overflow in test")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
out.extend_from_slice(name);
|
||||
out.push(0);
|
||||
}
|
||||
None => out.extend_from_slice(&0u32.to_le_bytes()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
|
||||
let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
|
||||
res10_names_raw(&raw)
|
||||
}
|
||||
|
||||
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
|
||||
vec![
|
||||
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
|
||||
synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
|
||||
synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
|
||||
synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
|
||||
synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_all_game_msh_models() {
|
||||
let archives = nres_test_files();
|
||||
if archives.is_empty() {
|
||||
eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut model_count = 0usize;
|
||||
let mut renderable_count = 0usize;
|
||||
let mut legacy_stride24_count = 0usize;
|
||||
|
||||
for archive_path in archives {
|
||||
let archive = Archive::open_path(&archive_path)
|
||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||
|
||||
for entry in archive.entries() {
|
||||
if !is_msh_name(&entry.meta.name) {
|
||||
continue;
|
||||
}
|
||||
model_count += 1;
|
||||
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read model '{}' in {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to parse model '{}' in {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
|
||||
if model.node_stride == 24 {
|
||||
legacy_stride24_count += 1;
|
||||
}
|
||||
|
||||
for node_index in 0..model.node_count {
|
||||
for lod in 0..3 {
|
||||
for group in 0..5 {
|
||||
if let Some(slot_idx) = model.slot_index(node_index, lod, group) {
|
||||
assert!(
|
||||
slot_idx < model.slots.len(),
|
||||
"slot index out of bounds in '{}' ({})",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut has_renderable_batch = false;
|
||||
for node_index in 0..model.node_count {
|
||||
let Some(slot_idx) = model.slot_index(node_index, 0, 0) else {
|
||||
continue;
|
||||
};
|
||||
let slot = &model.slots[slot_idx];
|
||||
let batch_end =
|
||||
usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count));
|
||||
if batch_end > model.batches.len() {
|
||||
continue;
|
||||
}
|
||||
for batch in &model.batches[usize::from(slot.batch_start)..batch_end] {
|
||||
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
|
||||
let index_count = usize::from(batch.index_count);
|
||||
let end = index_start.saturating_add(index_count);
|
||||
if end <= model.indices.len() && index_count >= 3 {
|
||||
has_renderable_batch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if has_renderable_batch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if has_renderable_batch {
|
||||
renderable_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(model_count > 0, "no .msh entries found");
|
||||
assert!(
|
||||
renderable_count > 0,
|
||||
"no renderable models (lod0/group0) were detected"
|
||||
);
|
||||
assert!(
|
||||
legacy_stride24_count <= model_count,
|
||||
"internal test accounting error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_synthetic_model() {
|
||||
let payload = build_nested_nres(&base_synthetic_entries());
|
||||
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
|
||||
assert_eq!(model.node_count, 1);
|
||||
assert_eq!(model.positions.len(), 3);
|
||||
assert_eq!(model.indices.len(), 3);
|
||||
assert_eq!(model.batches.len(), 1);
|
||||
assert_eq!(model.slot_index(0, 0, 0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_synthetic_stride24_variant() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
|
||||
assert_eq!(model.node_stride, 24);
|
||||
assert_eq!(model.node_count, 1);
|
||||
assert_eq!(model.slot_index(0, 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_synthetic_model_with_optional_res4_res5_res10() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
|
||||
entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
|
||||
entries.push(synthetic_entry(
|
||||
RES10_NAMES,
|
||||
"Res10",
|
||||
1,
|
||||
res10_names(&[Some("Hull"), None]),
|
||||
));
|
||||
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
|
||||
assert_eq!(model.node_count, 2);
|
||||
assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
|
||||
assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
|
||||
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_res10_names_decodes_cp1251() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
|
||||
entries.push(synthetic_entry(
|
||||
RES10_NAMES,
|
||||
"Res10",
|
||||
1,
|
||||
res10_names_raw(&[Some(&[0xC0])]),
|
||||
));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
|
||||
assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_when_required_resource_missing() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries.retain(|entry| entry.kind != RES13_BATCHES);
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::MissingResource {
|
||||
kind: RES13_BATCHES,
|
||||
label: "Res13"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_for_invalid_res2_size() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::InvalidRes2Size { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_for_unsupported_node_stride() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::UnsupportedNodeStride { stride: 30 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_for_invalid_optional_resource_size() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::InvalidResourceSize { label: "Res4", .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_for_slot_batch_range_out_of_bounds() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::IndexOutOfBounds {
|
||||
label: "Res2.batch_range",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_for_batch_index_range_out_of_bounds() {
|
||||
let mut entries = base_synthetic_entries();
|
||||
entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
|
||||
let payload = build_nested_nres(&entries);
|
||||
|
||||
assert!(matches!(
|
||||
parse_model_payload(&payload),
|
||||
Err(Error::IndexOutOfBounds {
|
||||
label: "Res13.index_range",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||
|
||||
#[test]
|
||||
fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
|
||||
let _ = parse_model_payload(&data);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "nres"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
@@ -1,42 +0,0 @@
|
||||
# nres
|
||||
|
||||
Rust-библиотека для работы с архивами формата **NRes**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
|
||||
- Поддержка `raw_mode` (весь файл как единый ресурс).
|
||||
- Чтение метаданных и итерация по записям.
|
||||
- Поиск по имени без учёта регистра (`find`).
|
||||
- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
|
||||
- Редактирование архива через `Editor`:
|
||||
- `add`, `replace_data`, `remove`.
|
||||
- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/nres/**`.
|
||||
- Сейчас в наборе: **120 архивов**.
|
||||
- Для каждого архива проверяется:
|
||||
- чтение всех записей;
|
||||
- `read`/`read_into`/`raw_slice`;
|
||||
- `find`;
|
||||
- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
|
||||
- Проверка валидации и ошибок:
|
||||
- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p nres -- --nocapture
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
|
||||
InvalidMagic {
|
||||
got: [u8; 4],
|
||||
},
|
||||
UnsupportedVersion {
|
||||
got: u32,
|
||||
},
|
||||
TotalSizeMismatch {
|
||||
header: u32,
|
||||
actual: u64,
|
||||
},
|
||||
|
||||
InvalidEntryCount {
|
||||
got: i32,
|
||||
},
|
||||
TooManyEntries {
|
||||
got: usize,
|
||||
},
|
||||
DirectoryOutOfBounds {
|
||||
directory_offset: u64,
|
||||
directory_len: u64,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
EntryIdOutOfRange {
|
||||
id: u32,
|
||||
entry_count: u32,
|
||||
},
|
||||
EntryDataOutOfBounds {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
directory_offset: u64,
|
||||
},
|
||||
NameTooLong {
|
||||
got: usize,
|
||||
max: usize,
|
||||
},
|
||||
NameContainsNul,
|
||||
BadNameEncoding,
|
||||
|
||||
IntegerOverflow,
|
||||
|
||||
RawModeDisallowsOperation(&'static str),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
|
||||
Error::UnsupportedVersion { got } => {
|
||||
write!(f, "unsupported NRes version: {got:#x}")
|
||||
}
|
||||
Error::TotalSizeMismatch { header, actual } => {
|
||||
write!(f, "NRes total_size mismatch: header={header}, actual={actual}")
|
||||
}
|
||||
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
|
||||
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
|
||||
Error::DirectoryOutOfBounds {
|
||||
directory_offset,
|
||||
directory_len,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}"
|
||||
),
|
||||
Error::EntryIdOutOfRange { id, entry_count } => {
|
||||
write!(f, "entry id out of range: id={id}, count={entry_count}")
|
||||
}
|
||||
Error::EntryDataOutOfBounds {
|
||||
id,
|
||||
offset,
|
||||
size,
|
||||
directory_offset,
|
||||
} => write!(
|
||||
f,
|
||||
"entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}"
|
||||
),
|
||||
Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"),
|
||||
Error::NameContainsNul => write!(f, "name contains NUL byte"),
|
||||
Error::BadNameEncoding => write!(f, "bad name encoding"),
|
||||
Error::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Error::RawModeDisallowsOperation(op) => {
|
||||
write!(f, "operation not allowed in raw mode: {op}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,772 +0,0 @@
|
||||
pub mod error;
|
||||
|
||||
use crate::error::Error;
|
||||
use common::{OutputBuffer, ResourceData};
|
||||
use core::ops::Range;
|
||||
use std::cmp::Ordering;
|
||||
use std::fs::{self, OpenOptions as FsOpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OpenOptions {
|
||||
pub raw_mode: bool,
|
||||
pub sequential_hint: bool,
|
||||
pub prefetch_pages: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum OpenMode {
|
||||
#[default]
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArchiveHeader {
|
||||
pub magic: [u8; 4],
|
||||
pub version: u32,
|
||||
pub entry_count: u32,
|
||||
pub total_size: u32,
|
||||
pub directory_offset: u64,
|
||||
pub directory_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArchiveInfo {
|
||||
pub raw_mode: bool,
|
||||
pub file_size: u64,
|
||||
pub header: Option<ArchiveHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Archive {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
info: ArchiveInfo,
|
||||
raw_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct EntryId(pub u32);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub kind: u32,
|
||||
pub attr1: u32,
|
||||
pub attr2: u32,
|
||||
pub attr3: u32,
|
||||
pub name: String,
|
||||
pub data_offset: u64,
|
||||
pub data_size: u32,
|
||||
pub sort_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryRef<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryInspect<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
pub name_raw: &'a [u8; 36],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EntryRecord {
|
||||
meta: EntryMeta,
|
||||
name_raw: [u8; 36],
|
||||
}
|
||||
|
||||
impl Archive {
|
||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(
|
||||
path: impl AsRef<Path>,
|
||||
_mode: OpenMode,
|
||||
opts: OpenOptions,
|
||||
) -> Result<Self> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
Self::open_bytes(arc, opts)
|
||||
}
|
||||
|
||||
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
|
||||
let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
|
||||
if opts.prefetch_pages {
|
||||
prefetch_pages(&bytes);
|
||||
}
|
||||
Ok(Self {
|
||||
bytes,
|
||||
entries,
|
||||
info: ArchiveInfo {
|
||||
raw_mode: opts.raw_mode,
|
||||
file_size,
|
||||
header,
|
||||
},
|
||||
raw_mode: opts.raw_mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &ArchiveInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryRef {
|
||||
id: EntryId(id),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryInspect {
|
||||
id: EntryId(id),
|
||||
meta: &entry.meta,
|
||||
name_raw: &entry.name_raw,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.raw_mode {
|
||||
let mut low = 0usize;
|
||||
let mut high = self.entries.len();
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2;
|
||||
let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else {
|
||||
break;
|
||||
};
|
||||
if target_idx >= self.entries.len() {
|
||||
break;
|
||||
}
|
||||
let cmp = cmp_name_case_insensitive(
|
||||
name.as_bytes(),
|
||||
entry_name_bytes(&self.entries[target_idx].name_raw),
|
||||
);
|
||||
match cmp {
|
||||
Ordering::Less => high = mid,
|
||||
Ordering::Greater => low = mid + 1,
|
||||
Ordering::Equal => {
|
||||
let id = u32::try_from(target_idx).ok()?;
|
||||
return Some(EntryId(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
||||
== Ordering::Equal
|
||||
{
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryId(id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryInspect {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
name_raw: &entry.name_raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
||||
let range = self.entry_range(id)?;
|
||||
out.write_exact(&self.bytes[range.clone()])?;
|
||||
Ok(range.len())
|
||||
}
|
||||
|
||||
pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
|
||||
let range = self.entry_range(id)?;
|
||||
Ok(Some(&self.bytes[range]))
|
||||
}
|
||||
|
||||
pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
let bytes = fs::read(&path_buf)?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
let (entries, _) = parse_archive(&arc, false)?;
|
||||
let mut editable = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?;
|
||||
editable.push(EditableEntry {
|
||||
meta: entry.meta.clone(),
|
||||
name_raw: entry.name_raw,
|
||||
data: EntryData::Borrowed(range), // Copy-on-write: only store range
|
||||
});
|
||||
}
|
||||
Ok(Editor {
|
||||
path: path_buf,
|
||||
source: arc,
|
||||
entries: editable,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_range(&self, id: EntryId) -> Result<Range<usize>> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
let Some(entry) = self.entries.get(idx) else {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: saturating_u32_len(self.entries.len()),
|
||||
});
|
||||
};
|
||||
checked_range(
|
||||
entry.meta.data_offset,
|
||||
entry.meta.data_size,
|
||||
self.bytes.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Editor {
|
||||
path: PathBuf,
|
||||
source: Arc<[u8]>,
|
||||
entries: Vec<EditableEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum EntryData {
|
||||
Borrowed(Range<usize>),
|
||||
Modified(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EditableEntry {
|
||||
meta: EntryMeta,
|
||||
name_raw: [u8; 36],
|
||||
data: EntryData,
|
||||
}
|
||||
|
||||
impl EditableEntry {
|
||||
fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] {
|
||||
match &self.data {
|
||||
EntryData::Borrowed(range) => &source[range.clone()],
|
||||
EntryData::Modified(vec) => vec.as_slice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewEntry<'a> {
|
||||
pub kind: u32,
|
||||
pub attr1: u32,
|
||||
pub attr2: u32,
|
||||
pub attr3: u32,
|
||||
pub name: &'a str,
|
||||
pub data: &'a [u8],
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryRef {
|
||||
id: EntryId(id),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
|
||||
let name_raw = encode_name_field(entry.name)?;
|
||||
let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
self.entries.push(EditableEntry {
|
||||
meta: EntryMeta {
|
||||
kind: entry.kind,
|
||||
attr1: entry.attr1,
|
||||
attr2: entry.attr2,
|
||||
attr3: entry.attr3,
|
||||
name: decode_name(entry_name_bytes(&name_raw)),
|
||||
data_offset: 0,
|
||||
data_size,
|
||||
sort_index: 0,
|
||||
},
|
||||
name_raw,
|
||||
data: EntryData::Modified(entry.data.to_vec()),
|
||||
});
|
||||
Ok(EntryId(id_u32))
|
||||
}
|
||||
|
||||
pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
let Some(entry) = self.entries.get_mut(idx) else {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: saturating_u32_len(self.entries.len()),
|
||||
});
|
||||
};
|
||||
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
// Replace with new data (triggers copy-on-write if borrowed)
|
||||
entry.data = EntryData::Modified(data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EntryId) -> Result<()> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
if idx >= self.entries.len() {
|
||||
return Err(Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: saturating_u32_len(self.entries.len()),
|
||||
});
|
||||
}
|
||||
self.entries.remove(idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> Result<()> {
|
||||
let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Pre-calculate capacity to avoid reallocations
|
||||
let total_data_size: usize = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.data_slice(&self.source).len())
|
||||
.sum();
|
||||
let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry
|
||||
let directory_size = self.entries.len() * 64; // 64 bytes per entry
|
||||
let capacity = 16 + total_data_size + padding_estimate + directory_size;
|
||||
|
||||
let mut out = Vec::with_capacity(capacity);
|
||||
out.resize(16, 0); // Header
|
||||
|
||||
// Keep reference to source for copy-on-write
|
||||
let source = &self.source;
|
||||
|
||||
for entry in &mut self.entries {
|
||||
entry.meta.data_offset =
|
||||
u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Calculate size and get slice separately to avoid borrow conflicts
|
||||
let data_len = entry.data_slice(source).len();
|
||||
entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Now get the slice again for writing
|
||||
let data_slice = entry.data_slice(source);
|
||||
out.extend_from_slice(data_slice);
|
||||
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
if padding > 0 {
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut sort_order: Vec<usize> = (0..self.entries.len()).collect();
|
||||
sort_order.sort_by(|a, b| {
|
||||
cmp_name_case_insensitive(
|
||||
entry_name_bytes(&self.entries[*a].name_raw),
|
||||
entry_name_bytes(&self.entries[*b].name_raw),
|
||||
)
|
||||
});
|
||||
|
||||
for (idx, entry) in self.entries.iter_mut().enumerate() {
|
||||
// sort_index stores the original-entry index at sorted position `idx`.
|
||||
// This mirrors the format emitted by the retail assets and test fixtures.
|
||||
entry.meta.sort_index =
|
||||
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
for entry in &self.entries {
|
||||
let data_offset_u32 =
|
||||
u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?;
|
||||
push_u32(&mut out, entry.meta.kind);
|
||||
push_u32(&mut out, entry.meta.attr1);
|
||||
push_u32(&mut out, entry.meta.attr2);
|
||||
push_u32(&mut out, entry.meta.data_size);
|
||||
push_u32(&mut out, entry.meta.attr3);
|
||||
out.extend_from_slice(&entry.name_raw);
|
||||
push_u32(&mut out, data_offset_u32);
|
||||
push_u32(&mut out, entry.meta.sort_index);
|
||||
}
|
||||
|
||||
let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&count_u32.to_le_bytes());
|
||||
out[12..16].copy_from_slice(&total_size_u32.to_le_bytes());
|
||||
|
||||
write_atomic(&self.path, &out)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_archive(
|
||||
bytes: &[u8],
|
||||
raw_mode: bool,
|
||||
) -> Result<(Vec<EntryRecord>, Option<ArchiveHeader>)> {
|
||||
if raw_mode {
|
||||
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
let entry = EntryRecord {
|
||||
meta: EntryMeta {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: String::from("RAW"),
|
||||
data_offset: 0,
|
||||
data_size,
|
||||
sort_index: 0,
|
||||
},
|
||||
name_raw: {
|
||||
let mut name = [0u8; 36];
|
||||
let bytes_name = b"RAW";
|
||||
name[..bytes_name.len()].copy_from_slice(bytes_name);
|
||||
name
|
||||
},
|
||||
};
|
||||
return Ok((vec![entry], None));
|
||||
}
|
||||
|
||||
if bytes.len() < 16 {
|
||||
let mut got = [0u8; 4];
|
||||
let copy_len = bytes.len().min(4);
|
||||
got[..copy_len].copy_from_slice(&bytes[..copy_len]);
|
||||
return Err(Error::InvalidMagic { got });
|
||||
}
|
||||
|
||||
let mut magic = [0u8; 4];
|
||||
magic.copy_from_slice(&bytes[0..4]);
|
||||
if &magic != b"NRes" {
|
||||
return Err(Error::InvalidMagic { got: magic });
|
||||
}
|
||||
|
||||
let version = read_u32(bytes, 4)?;
|
||||
if version != 0x100 {
|
||||
return Err(Error::UnsupportedVersion { got: version });
|
||||
}
|
||||
|
||||
let entry_count_i32 = i32::from_le_bytes(
|
||||
bytes[8..12]
|
||||
.try_into()
|
||||
.map_err(|_| Error::IntegerOverflow)?,
|
||||
);
|
||||
if entry_count_i32 < 0 {
|
||||
return Err(Error::InvalidEntryCount {
|
||||
got: entry_count_i32,
|
||||
});
|
||||
}
|
||||
let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Validate entry_count fits in u32 (required for EntryId)
|
||||
if entry_count > u32::MAX as usize {
|
||||
return Err(Error::TooManyEntries { got: entry_count });
|
||||
}
|
||||
|
||||
let total_size = read_u32(bytes, 12)?;
|
||||
let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
if u64::from(total_size) != actual_size {
|
||||
return Err(Error::TotalSizeMismatch {
|
||||
header: total_size,
|
||||
actual: actual_size,
|
||||
});
|
||||
}
|
||||
|
||||
let directory_len = u64::try_from(entry_count)
|
||||
.map_err(|_| Error::IntegerOverflow)?
|
||||
.checked_mul(64)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let directory_offset =
|
||||
u64::from(total_size)
|
||||
.checked_sub(directory_len)
|
||||
.ok_or(Error::DirectoryOutOfBounds {
|
||||
directory_offset: 0,
|
||||
directory_len,
|
||||
file_len: actual_size,
|
||||
})?;
|
||||
|
||||
if directory_offset < 16 || directory_offset + directory_len > actual_size {
|
||||
return Err(Error::DirectoryOutOfBounds {
|
||||
directory_offset,
|
||||
directory_len,
|
||||
file_len: actual_size,
|
||||
});
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(entry_count);
|
||||
for index in 0..entry_count {
|
||||
let base = usize::try_from(directory_offset)
|
||||
.map_err(|_| Error::IntegerOverflow)?
|
||||
.checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
|
||||
let kind = read_u32(bytes, base)?;
|
||||
let attr1 = read_u32(bytes, base + 4)?;
|
||||
let attr2 = read_u32(bytes, base + 8)?;
|
||||
let data_size = read_u32(bytes, base + 12)?;
|
||||
let attr3 = read_u32(bytes, base + 16)?;
|
||||
|
||||
let mut name_raw = [0u8; 36];
|
||||
let name_slice = bytes
|
||||
.get(base + 20..base + 56)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
name_raw.copy_from_slice(name_slice);
|
||||
|
||||
let name_bytes = entry_name_bytes(&name_raw);
|
||||
if name_bytes.len() > 35 {
|
||||
return Err(Error::NameTooLong {
|
||||
got: name_bytes.len(),
|
||||
max: 35,
|
||||
});
|
||||
}
|
||||
|
||||
let data_offset = u64::from(read_u32(bytes, base + 56)?);
|
||||
let sort_index = read_u32(bytes, base + 60)?;
|
||||
|
||||
let end = data_offset
|
||||
.checked_add(u64::from(data_size))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if data_offset < 16 || end > directory_offset {
|
||||
return Err(Error::EntryDataOutOfBounds {
|
||||
id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?,
|
||||
offset: data_offset,
|
||||
size: data_size,
|
||||
directory_offset,
|
||||
});
|
||||
}
|
||||
|
||||
entries.push(EntryRecord {
|
||||
meta: EntryMeta {
|
||||
kind,
|
||||
attr1,
|
||||
attr2,
|
||||
attr3,
|
||||
name: decode_name(name_bytes),
|
||||
data_offset,
|
||||
data_size,
|
||||
sort_index,
|
||||
},
|
||||
name_raw,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((
|
||||
entries,
|
||||
Some(ArchiveHeader {
|
||||
magic: *b"NRes",
|
||||
version,
|
||||
entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
|
||||
total_size,
|
||||
directory_offset,
|
||||
directory_size: directory_len,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
||||
let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?;
|
||||
let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?;
|
||||
let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?;
|
||||
if end > bytes_len {
|
||||
return Err(Error::IntegerOverflow);
|
||||
}
|
||||
Ok(start..end)
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
|
||||
let data = bytes
|
||||
.get(offset..offset + 4)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn encode_name_field(name: &str) -> Result<[u8; 36]> {
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.contains(&0) {
|
||||
return Err(Error::NameContainsNul);
|
||||
}
|
||||
if bytes.len() > 35 {
|
||||
return Err(Error::NameTooLong {
|
||||
got: bytes.len(),
|
||||
max: 35,
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = [0u8; 36];
|
||||
out[..bytes.len()].copy_from_slice(bytes);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] {
|
||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
}
|
||||
|
||||
fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|b| char::from(*b)).collect()
|
||||
}
|
||||
|
||||
fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering {
|
||||
let mut idx = 0usize;
|
||||
let min_len = a.len().min(b.len());
|
||||
while idx < min_len {
|
||||
let left = ascii_lower(a[idx]);
|
||||
let right = ascii_lower(b[idx]);
|
||||
if left != right {
|
||||
return left.cmp(&right);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
a.len().cmp(&b.len())
|
||||
}
|
||||
|
||||
fn ascii_lower(value: u8) -> u8 {
|
||||
if value.is_ascii_uppercase() {
|
||||
value + 32
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn saturating_u32_len(len: usize) -> u32 {
|
||||
u32::try_from(len).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
fn prefetch_pages(bytes: &[u8]) {
|
||||
use std::hint::black_box;
|
||||
|
||||
let mut cursor = 0usize;
|
||||
let mut sink = 0u8;
|
||||
while cursor < bytes.len() {
|
||||
sink ^= bytes[cursor];
|
||||
cursor = cursor.saturating_add(4096);
|
||||
}
|
||||
black_box(sink);
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("archive");
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
|
||||
let mut temp_path = None;
|
||||
for attempt in 0..128u32 {
|
||||
let name = format!(
|
||||
".{}.tmp.{}.{}.{}",
|
||||
file_name,
|
||||
std::process::id(),
|
||||
unix_time_nanos(),
|
||||
attempt
|
||||
);
|
||||
let candidate = parent.join(name);
|
||||
let opened = FsOpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&candidate);
|
||||
if let Ok(mut file) = opened {
|
||||
file.write_all(content)?;
|
||||
file.sync_all()?;
|
||||
temp_path = Some((candidate, file));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some((tmp_path, mut file)) = temp_path else {
|
||||
return Err(Error::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"failed to create temporary file for atomic write",
|
||||
)));
|
||||
};
|
||||
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
|
||||
if let Err(err) = replace_file_atomically(&tmp_path, path) {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
return Err(Error::Io(err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
fs::rename(src, dst)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
use std::iter;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||
|
||||
// SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
|
||||
// for the duration of the call; flags and argument contract match WinAPI.
|
||||
let ok = unsafe {
|
||||
MoveFileExW(
|
||||
src_wide.as_ptr(),
|
||||
dst_wide.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_time_nanos() -> u128 {
|
||||
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_nanos(),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,983 +0,0 @@
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use std::any::Any;
|
||||
use std::fs;
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SyntheticEntry<'a> {
|
||||
kind: u32,
|
||||
attr1: u32,
|
||||
attr2: u32,
|
||||
attr3: u32,
|
||||
name: &'a str,
|
||||
data: &'a [u8],
|
||||
}
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("nres");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
fs::read(path)
|
||||
.map(|data| data.get(0..4) == Some(b"NRes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
let file_name = original
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("archive");
|
||||
path.push(format!(
|
||||
"nres-test-{}-{}-{}",
|
||||
std::process::id(),
|
||||
unix_time_nanos(),
|
||||
file_name
|
||||
));
|
||||
fs::write(&path, bytes).expect("failed to create temp file");
|
||||
path
|
||||
}
|
||||
|
||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
||||
let any = payload.as_ref();
|
||||
if let Some(message) = any.downcast_ref::<String>() {
|
||||
return message.clone();
|
||||
}
|
||||
if let Some(message) = any.downcast_ref::<&str>() {
|
||||
return (*message).to_string();
|
||||
}
|
||||
String::from("panic without message")
|
||||
}
|
||||
|
||||
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
||||
let slice = bytes
|
||||
.get(offset..offset + 4)
|
||||
.expect("u32 read out of bounds in test");
|
||||
let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
|
||||
u32::from_le_bytes(arr)
|
||||
}
|
||||
|
||||
fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
|
||||
let slice = bytes
|
||||
.get(offset..offset + 4)
|
||||
.expect("i32 read out of bounds in test");
|
||||
let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test");
|
||||
i32::from_le_bytes(arr)
|
||||
}
|
||||
|
||||
fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> {
|
||||
let nul = raw.iter().position(|value| *value == 0)?;
|
||||
Some(&raw[..nul])
|
||||
}
|
||||
|
||||
fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; 16];
|
||||
let mut offsets = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in entries {
|
||||
offsets.push(u32::try_from(out.len()).expect("offset overflow"));
|
||||
out.extend_from_slice(entry.data);
|
||||
let padding = (8 - (out.len() % 8)) % 8;
|
||||
if padding > 0 {
|
||||
out.resize(out.len() + padding, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut sort_order: Vec<usize> = (0..entries.len()).collect();
|
||||
sort_order.sort_by(|a, b| {
|
||||
cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes())
|
||||
});
|
||||
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let mut name_raw = [0u8; 36];
|
||||
let name_bytes = entry.name.as_bytes();
|
||||
assert!(name_bytes.len() <= 35, "name too long in fixture");
|
||||
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||
|
||||
push_u32(&mut out, entry.kind);
|
||||
push_u32(&mut out, entry.attr1);
|
||||
push_u32(&mut out, entry.attr2);
|
||||
push_u32(
|
||||
&mut out,
|
||||
u32::try_from(entry.data.len()).expect("data size overflow"),
|
||||
);
|
||||
push_u32(&mut out, entry.attr3);
|
||||
out.extend_from_slice(&name_raw);
|
||||
push_u32(&mut out, offsets[index]);
|
||||
push_u32(
|
||||
&mut out,
|
||||
u32::try_from(sort_order[index]).expect("sort index overflow"),
|
||||
);
|
||||
}
|
||||
|
||||
out[0..4].copy_from_slice(b"NRes");
|
||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
||||
out[8..12].copy_from_slice(
|
||||
&u32::try_from(entries.len())
|
||||
.expect("count overflow")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
let total_size = u32::try_from(out.len()).expect("size overflow");
|
||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_docs_structural_invariants_all_files() {
|
||||
let files = nres_test_files();
|
||||
if files.is_empty() {
|
||||
eprintln!(
|
||||
"skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for path in files {
|
||||
let bytes = fs::read(&path).unwrap_or_else(|err| {
|
||||
panic!("failed to read {}: {err}", path.display());
|
||||
});
|
||||
|
||||
assert!(
|
||||
bytes.len() >= 16,
|
||||
"NRes header too short in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display());
|
||||
assert_eq!(
|
||||
read_u32_le(&bytes, 4),
|
||||
0x100,
|
||||
"bad version in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"),
|
||||
bytes.len(),
|
||||
"header.total_size mismatch in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let entry_count_i32 = read_i32_le(&bytes, 8);
|
||||
assert!(
|
||||
entry_count_i32 >= 0,
|
||||
"negative entry_count={} in {}",
|
||||
entry_count_i32,
|
||||
path.display()
|
||||
);
|
||||
let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow");
|
||||
let directory_len = entry_count.checked_mul(64).expect("directory_len overflow");
|
||||
let directory_offset = bytes
|
||||
.len()
|
||||
.checked_sub(directory_len)
|
||||
.unwrap_or_else(|| panic!("directory underflow in {}", path.display()));
|
||||
assert!(
|
||||
directory_offset >= 16,
|
||||
"directory offset before data area in {}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
directory_offset + directory_len,
|
||||
bytes.len(),
|
||||
"directory not at file end in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let mut sort_indices = Vec::with_capacity(entry_count);
|
||||
let mut entries = Vec::with_capacity(entry_count);
|
||||
for index in 0..entry_count {
|
||||
let base = directory_offset + index * 64;
|
||||
let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
|
||||
let data_offset =
|
||||
usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
|
||||
let sort_index =
|
||||
usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow");
|
||||
|
||||
let mut name_raw = [0u8; 36];
|
||||
name_raw.copy_from_slice(
|
||||
bytes
|
||||
.get(base + 20..base + 56)
|
||||
.expect("name field out of bounds in test"),
|
||||
);
|
||||
let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"name field without NUL terminator in {} entry #{index}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
name_bytes.len() <= 35,
|
||||
"name longer than 35 bytes in {} entry #{index}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
sort_indices.push(sort_index);
|
||||
entries.push((name_bytes.to_vec(), data_offset, size));
|
||||
}
|
||||
|
||||
let mut expected_sort: Vec<usize> = (0..entry_count).collect();
|
||||
expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0));
|
||||
assert_eq!(
|
||||
sort_indices,
|
||||
expected_sort,
|
||||
"sort_index table mismatch in {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let mut data_regions: Vec<(usize, usize)> =
|
||||
entries.iter().map(|(_, off, size)| (*off, *size)).collect();
|
||||
data_regions.sort_by_key(|(off, _)| *off);
|
||||
|
||||
for (idx, (data_offset, size)) in data_regions.iter().enumerate() {
|
||||
assert_eq!(
|
||||
data_offset % 8,
|
||||
0,
|
||||
"data offset is not 8-byte aligned in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
*data_offset >= 16,
|
||||
"data offset before header end in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset,
|
||||
"data region overlaps directory in {} (region #{idx})",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
for pair in data_regions.windows(2) {
|
||||
let (start, size) = pair[0];
|
||||
let (next_start, _) = pair[1];
|
||||
let end = start
|
||||
.checked_add(size)
|
||||
.unwrap_or_else(|| panic!("size overflow in {}", path.display()));
|
||||
assert!(
|
||||
end <= next_start,
|
||||
"overlapping data regions in {}: [{start}, {end}) and next at {next_start}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
for (offset, value) in bytes[end..next_start].iter().enumerate() {
|
||||
assert_eq!(
|
||||
*value,
|
||||
0,
|
||||
"non-zero alignment padding in {} at offset {}",
|
||||
path.display(),
|
||||
end + offset
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_read_and_roundtrip_all_files() {
|
||||
let files = nres_test_files();
|
||||
if files.is_empty() {
|
||||
eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres");
|
||||
return;
|
||||
}
|
||||
|
||||
let checked = files.len();
|
||||
let mut success = 0usize;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for path in files {
|
||||
let display_path = path.display().to_string();
|
||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||
let original = fs::read(&path).expect("failed to read archive");
|
||||
let archive = Archive::open_path(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
|
||||
|
||||
let count = archive.entry_count();
|
||||
assert_eq!(
|
||||
count,
|
||||
archive.entries().count(),
|
||||
"entry count mismatch: {}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
for idx in 0..count {
|
||||
let id = EntryId(idx as u32);
|
||||
let entry = archive
|
||||
.get(id)
|
||||
.unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
|
||||
|
||||
let payload = archive.read(id).unwrap_or_else(|err| {
|
||||
panic!("read failed for {} entry #{idx}: {err}", path.display())
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
let written = archive.read_into(id, &mut out).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"read_into failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
written,
|
||||
payload.as_slice().len(),
|
||||
"size mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
out.as_slice(),
|
||||
payload.as_slice(),
|
||||
"payload mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let raw = archive
|
||||
.raw_slice(id)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"raw_slice failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.expect("raw_slice must return Some for file-backed archive");
|
||||
assert_eq!(
|
||||
raw,
|
||||
payload.as_slice(),
|
||||
"raw slice mismatch in {} entry #{idx}",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let found = archive.find(&entry.meta.name).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"find failed for name '{}' in {}",
|
||||
entry.meta.name,
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
let found_meta = archive.get(found).expect("find returned invalid id");
|
||||
assert!(
|
||||
found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name),
|
||||
"find returned unrelated entry in {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_copy = make_temp_copy(&path, &original);
|
||||
let mut editor = Archive::edit_path(&temp_copy)
|
||||
.unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display()));
|
||||
|
||||
for idx in 0..count {
|
||||
let data = archive
|
||||
.read(EntryId(idx as u32))
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"read before replace failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.into_owned();
|
||||
editor
|
||||
.replace_data(EntryId(idx as u32), &data)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"replace_data failed for {} entry #{idx}: {err}",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
editor
|
||||
.commit()
|
||||
.unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display()));
|
||||
let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive");
|
||||
let _ = fs::remove_file(&temp_copy);
|
||||
|
||||
assert_eq!(
|
||||
original,
|
||||
rebuilt,
|
||||
"byte-to-byte roundtrip mismatch for {}",
|
||||
path.display()
|
||||
);
|
||||
}));
|
||||
|
||||
match result {
|
||||
Ok(()) => success += 1,
|
||||
Err(payload) => {
|
||||
failures.push(format!("{}: {}", display_path, panic_message(payload)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let failed = failures.len();
|
||||
eprintln!(
|
||||
"NRes summary: checked={}, success={}, failed={}",
|
||||
checked, success, failed
|
||||
);
|
||||
if !failures.is_empty() {
|
||||
panic!(
|
||||
"NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
|
||||
checked,
|
||||
success,
|
||||
failed,
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_raw_mode_exposes_whole_file() {
|
||||
let files = nres_test_files();
|
||||
let Some(first) = files.first() else {
|
||||
eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres");
|
||||
return;
|
||||
};
|
||||
let original = fs::read(first).expect("failed to read archive");
|
||||
let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice());
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
arc,
|
||||
OpenOptions {
|
||||
raw_mode: true,
|
||||
sequential_hint: false,
|
||||
prefetch_pages: false,
|
||||
},
|
||||
)
|
||||
.expect("raw mode open failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
let data = archive.read(EntryId(0)).expect("raw read failed");
|
||||
assert_eq!(data.as_slice(), original.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_raw_mode_accepts_non_nres_bytes() {
|
||||
let payload = b"not-an-nres-archive".to_vec();
|
||||
let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice());
|
||||
|
||||
match Archive::open_bytes(bytes.clone(), OpenOptions::default()) {
|
||||
Err(Error::InvalidMagic { .. }) => {}
|
||||
other => panic!("expected InvalidMagic without raw_mode, got {other:?}"),
|
||||
}
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
bytes,
|
||||
OpenOptions {
|
||||
raw_mode: true,
|
||||
sequential_hint: false,
|
||||
prefetch_pages: false,
|
||||
},
|
||||
)
|
||||
.expect("raw_mode should accept any bytes");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
assert_eq!(archive.find("raw"), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
archive
|
||||
.read(EntryId(0))
|
||||
.expect("raw read failed")
|
||||
.as_slice(),
|
||||
payload.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_open_options_hints_do_not_change_payload() {
|
||||
let payload: Vec<u8> = (0..70_000u32).map(|v| (v % 251) as u8).collect();
|
||||
let src = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 7,
|
||||
attr1: 70,
|
||||
attr2: 700,
|
||||
attr3: 7000,
|
||||
name: "big.bin",
|
||||
data: &payload,
|
||||
}]);
|
||||
let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice());
|
||||
|
||||
let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default())
|
||||
.expect("baseline open should succeed");
|
||||
let hinted = Archive::open_bytes(
|
||||
arc,
|
||||
OpenOptions {
|
||||
raw_mode: false,
|
||||
sequential_hint: true,
|
||||
prefetch_pages: true,
|
||||
},
|
||||
)
|
||||
.expect("open with hints should succeed");
|
||||
|
||||
assert_eq!(baseline.entry_count(), 1);
|
||||
assert_eq!(hinted.entry_count(), 1);
|
||||
assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0)));
|
||||
assert_eq!(hinted.find("big.bin"), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
baseline
|
||||
.read(EntryId(0))
|
||||
.expect("baseline read failed")
|
||||
.as_slice(),
|
||||
hinted
|
||||
.read(EntryId(0))
|
||||
.expect("hinted read failed")
|
||||
.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_commit_empty_archive_has_minimal_layout() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-empty-commit-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
|
||||
|
||||
Archive::edit_path(&path)
|
||||
.expect("edit_path failed for empty archive")
|
||||
.commit()
|
||||
.expect("commit failed for empty archive");
|
||||
|
||||
let bytes = fs::read(&path).expect("failed to read committed archive");
|
||||
assert_eq!(bytes.len(), 16, "empty archive must contain only header");
|
||||
assert_eq!(&bytes[0..4], b"NRes");
|
||||
assert_eq!(read_u32_le(&bytes, 4), 0x100);
|
||||
assert_eq!(read_u32_le(&bytes, 8), 0);
|
||||
assert_eq!(read_u32_le(&bytes, 12), 16);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_commit_recomputes_header_directory_and_sort_table() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-commit-layout-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 10,
|
||||
attr1: 1,
|
||||
attr2: 2,
|
||||
attr3: 3,
|
||||
name: "Zulu",
|
||||
data: b"aaaaa",
|
||||
})
|
||||
.expect("add #0 failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 11,
|
||||
attr1: 4,
|
||||
attr2: 5,
|
||||
attr3: 6,
|
||||
name: "alpha",
|
||||
data: b"bbbbbbbb",
|
||||
})
|
||||
.expect("add #1 failed");
|
||||
editor
|
||||
.add(NewEntry {
|
||||
kind: 12,
|
||||
attr1: 7,
|
||||
attr2: 8,
|
||||
attr3: 9,
|
||||
name: "Beta",
|
||||
data: b"cccc",
|
||||
})
|
||||
.expect("add #2 failed");
|
||||
editor.commit().expect("commit failed");
|
||||
|
||||
let bytes = fs::read(&path).expect("failed to read committed archive");
|
||||
assert_eq!(&bytes[0..4], b"NRes");
|
||||
assert_eq!(read_u32_le(&bytes, 4), 0x100);
|
||||
|
||||
let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow");
|
||||
let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow");
|
||||
assert_eq!(entry_count, 3);
|
||||
assert_eq!(total_size, bytes.len());
|
||||
|
||||
let directory_offset = total_size
|
||||
.checked_sub(entry_count * 64)
|
||||
.expect("invalid directory offset");
|
||||
assert!(directory_offset >= 16);
|
||||
|
||||
let mut sort_indices = Vec::new();
|
||||
let mut prev_data_end = 16usize;
|
||||
for idx in 0..entry_count {
|
||||
let base = directory_offset + idx * 64;
|
||||
let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
|
||||
let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
|
||||
let sort_index =
|
||||
usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow");
|
||||
|
||||
assert_eq!(
|
||||
data_offset % 8,
|
||||
0,
|
||||
"entry #{idx} data offset must be 8-byte aligned"
|
||||
);
|
||||
assert!(
|
||||
data_offset >= prev_data_end,
|
||||
"entry #{idx} offset regressed"
|
||||
);
|
||||
assert!(
|
||||
data_offset + data_size <= directory_offset,
|
||||
"entry #{idx} overlaps directory"
|
||||
);
|
||||
prev_data_end = data_offset + data_size;
|
||||
sort_indices.push(sort_index);
|
||||
}
|
||||
|
||||
let names = ["Zulu", "alpha", "Beta"];
|
||||
let mut expected_sort: Vec<usize> = (0..names.len()).collect();
|
||||
expected_sort
|
||||
.sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes()));
|
||||
assert_eq!(
|
||||
sort_indices, expected_sort,
|
||||
"sort table must contain original indexes in case-insensitive alphabetical order"
|
||||
);
|
||||
|
||||
let archive = Archive::open_path(&path).expect("re-open failed");
|
||||
assert_eq!(archive.find("zulu"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("ALPHA"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("beta"), Some(EntryId(2)));
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_synthetic_read_find_and_edit() {
|
||||
let payload_a = b"alpha";
|
||||
let payload_b = b"B";
|
||||
let payload_c = b"";
|
||||
let src = build_nres_bytes(&[
|
||||
SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 10,
|
||||
attr2: 20,
|
||||
attr3: 30,
|
||||
name: "Alpha.TXT",
|
||||
data: payload_a,
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 2,
|
||||
attr1: 11,
|
||||
attr2: 21,
|
||||
attr3: 31,
|
||||
name: "beta.bin",
|
||||
data: payload_b,
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 3,
|
||||
attr1: 12,
|
||||
attr2: 22,
|
||||
attr3: 32,
|
||||
name: "Gamma",
|
||||
data: payload_c,
|
||||
},
|
||||
]);
|
||||
|
||||
let archive = Archive::open_bytes(
|
||||
Arc::from(src.clone().into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
)
|
||||
.expect("open synthetic nres failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 3);
|
||||
assert_eq!(archive.find("alpha.txt"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("gAmMa"), Some(EntryId(2)));
|
||||
assert_eq!(archive.find("missing"), None);
|
||||
|
||||
assert_eq!(
|
||||
archive.read(EntryId(0)).expect("read #0 failed").as_slice(),
|
||||
payload_a
|
||||
);
|
||||
assert_eq!(
|
||||
archive.read(EntryId(1)).expect("read #1 failed").as_slice(),
|
||||
payload_b
|
||||
);
|
||||
assert_eq!(
|
||||
archive.read(EntryId(2)).expect("read #2 failed").as_slice(),
|
||||
payload_c
|
||||
);
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-synth-edit-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
fs::write(&path, &src).expect("write temp synthetic archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed");
|
||||
editor
|
||||
.replace_data(EntryId(1), b"replaced")
|
||||
.expect("replace_data failed");
|
||||
let added = editor
|
||||
.add(NewEntry {
|
||||
kind: 4,
|
||||
attr1: 13,
|
||||
attr2: 23,
|
||||
attr3: 33,
|
||||
name: "delta",
|
||||
data: b"new payload",
|
||||
})
|
||||
.expect("add failed");
|
||||
assert_eq!(added, EntryId(3));
|
||||
editor.remove(EntryId(2)).expect("remove failed");
|
||||
editor.commit().expect("commit failed");
|
||||
|
||||
let edited = Archive::open_path(&path).expect("re-open edited archive failed");
|
||||
assert_eq!(edited.entry_count(), 3);
|
||||
assert_eq!(
|
||||
edited
|
||||
.read(edited.find("beta.bin").expect("find beta.bin failed"))
|
||||
.expect("read beta.bin failed")
|
||||
.as_slice(),
|
||||
b"replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
edited
|
||||
.read(edited.find("delta").expect("find delta failed"))
|
||||
.expect("read delta failed")
|
||||
.as_slice(),
|
||||
b"new payload"
|
||||
);
|
||||
assert_eq!(edited.find("gamma"), None);
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_max_name_length_roundtrip() {
|
||||
let max_name = "12345678901234567890123456789012345";
|
||||
assert_eq!(max_name.len(), 35);
|
||||
|
||||
let src = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 9,
|
||||
attr1: 1,
|
||||
attr2: 2,
|
||||
attr3: 3,
|
||||
name: max_name,
|
||||
data: b"payload",
|
||||
}]);
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open synthetic nres failed");
|
||||
|
||||
assert_eq!(archive.entry_count(), 1);
|
||||
assert_eq!(archive.find(max_name), Some(EntryId(0)));
|
||||
assert_eq!(
|
||||
archive.find(&max_name.to_ascii_lowercase()),
|
||||
Some(EntryId(0))
|
||||
);
|
||||
|
||||
let entry = archive.get(EntryId(0)).expect("missing entry 0");
|
||||
assert_eq!(entry.meta.name, max_name);
|
||||
assert_eq!(
|
||||
archive
|
||||
.read(EntryId(0))
|
||||
.expect("read payload failed")
|
||||
.as_slice(),
|
||||
b"payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_find_falls_back_when_sort_index_is_out_of_range() {
|
||||
let mut bytes = build_nres_bytes(&[
|
||||
SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Alpha",
|
||||
data: b"a",
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 2,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Beta",
|
||||
data: b"b",
|
||||
},
|
||||
SyntheticEntry {
|
||||
kind: 3,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "Gamma",
|
||||
data: b"c",
|
||||
},
|
||||
]);
|
||||
|
||||
let entry_count = 3usize;
|
||||
let directory_offset = bytes
|
||||
.len()
|
||||
.checked_sub(entry_count * 64)
|
||||
.expect("directory offset underflow");
|
||||
let mid_entry_sort_index = directory_offset + 64 + 60;
|
||||
bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open archive with corrupted sort index failed");
|
||||
|
||||
assert_eq!(archive.find("alpha"), Some(EntryId(0)));
|
||||
assert_eq!(archive.find("BETA"), Some(EntryId(1)));
|
||||
assert_eq!(archive.find("gamma"), Some(EntryId(2)));
|
||||
assert_eq!(archive.find("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_validation_error_cases() {
|
||||
let valid = build_nres_bytes(&[SyntheticEntry {
|
||||
kind: 1,
|
||||
attr1: 2,
|
||||
attr2: 3,
|
||||
attr3: 4,
|
||||
name: "ok",
|
||||
data: b"1234",
|
||||
}]);
|
||||
|
||||
let mut invalid_magic = valid.clone();
|
||||
invalid_magic[0..4].copy_from_slice(b"FAIL");
|
||||
match Archive::open_bytes(
|
||||
Arc::from(invalid_magic.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::InvalidMagic { .. }) => {}
|
||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut invalid_version = valid.clone();
|
||||
invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(invalid_version.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200),
|
||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_total = valid.clone();
|
||||
bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_total.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::TotalSizeMismatch { .. }) => {}
|
||||
other => panic!("expected TotalSizeMismatch, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_count = valid.clone();
|
||||
bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_count.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
|
||||
other => panic!("expected InvalidEntryCount, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_dir = valid.clone();
|
||||
bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_dir.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::DirectoryOutOfBounds { .. }) => {}
|
||||
other => panic!("expected DirectoryOutOfBounds, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut long_name = valid.clone();
|
||||
let entry_base = long_name.len() - 64;
|
||||
for b in &mut long_name[entry_base + 20..entry_base + 56] {
|
||||
*b = b'X';
|
||||
}
|
||||
match Archive::open_bytes(
|
||||
Arc::from(long_name.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::NameTooLong { .. }) => {}
|
||||
other => panic!("expected NameTooLong, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut bad_data = valid.clone();
|
||||
bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes());
|
||||
bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes());
|
||||
match Archive::open_bytes(
|
||||
Arc::from(bad_data.into_boxed_slice()),
|
||||
OpenOptions::default(),
|
||||
) {
|
||||
Err(Error::EntryDataOutOfBounds { .. }) => {}
|
||||
other => panic!("expected EntryDataOutOfBounds, got {other:?}"),
|
||||
}
|
||||
|
||||
let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default())
|
||||
.expect("open valid archive failed");
|
||||
match archive.read(EntryId(99)) {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nres_editor_validation_error_cases() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"nres-editor-errors-{}-{}.lib",
|
||||
std::process::id(),
|
||||
unix_time_nanos()
|
||||
));
|
||||
let src = build_nres_bytes(&[]);
|
||||
fs::write(&path, src).expect("write empty archive failed");
|
||||
|
||||
let mut editor = Archive::edit_path(&path).expect("edit_path failed");
|
||||
|
||||
let long_name = "X".repeat(36);
|
||||
match editor.add(NewEntry {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: &long_name,
|
||||
data: b"",
|
||||
}) {
|
||||
Err(Error::NameTooLong { .. }) => {}
|
||||
other => panic!("expected NameTooLong, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.add(NewEntry {
|
||||
kind: 0,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "bad\0name",
|
||||
data: b"",
|
||||
}) {
|
||||
Err(Error::NameContainsNul) => {}
|
||||
other => panic!("expected NameContainsNul, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.replace_data(EntryId(0), b"x") {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
|
||||
match editor.remove(EntryId(0)) {
|
||||
Err(Error::EntryIdOutOfRange { .. }) => {}
|
||||
other => panic!("expected EntryIdOutOfRange, got {other:?}"),
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "render-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
msh-core = { path = "../msh-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
nres = { path = "../nres" }
|
||||
@@ -1,14 +0,0 @@
|
||||
# render-core
|
||||
|
||||
CPU-подготовка draw-данных для моделей `MSH`.
|
||||
|
||||
Покрывает:
|
||||
|
||||
- обход `node -> slot -> batch`;
|
||||
- раскрытие индексов в triangle-list (`position + uv0`);
|
||||
- расчёт bounds по вершинам.
|
||||
|
||||
Тесты:
|
||||
|
||||
- построение рендер-сеток на реальных `.msh` из `testdata`;
|
||||
- unit-test bounds.
|
||||
@@ -1,146 +0,0 @@
|
||||
use msh_core::Model;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderVertex {
|
||||
pub position: [f32; 3],
|
||||
pub uv0: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderMesh {
|
||||
pub vertices: Vec<RenderVertex>,
|
||||
pub indices: Vec<u16>,
|
||||
pub batch_count: usize,
|
||||
pub index_overflow: bool,
|
||||
}
|
||||
|
||||
impl RenderMesh {
|
||||
pub fn triangle_count(&self) -> usize {
|
||||
self.indices.len() / 3
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an indexed triangle mesh for a specific LOD/group pair.
|
||||
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
let mut index_remap: HashMap<usize, u16> = HashMap::new();
|
||||
let mut batch_count = 0usize;
|
||||
let mut index_overflow = false;
|
||||
let uv0 = model.uv0.as_ref();
|
||||
|
||||
for node_index in 0..model.node_count {
|
||||
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
|
||||
continue;
|
||||
};
|
||||
let Some(slot) = model.slots.get(slot_idx) else {
|
||||
continue;
|
||||
};
|
||||
let batch_start = usize::from(slot.batch_start);
|
||||
let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
|
||||
if batch_end > model.batches.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for batch in &model.batches[batch_start..batch_end] {
|
||||
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
|
||||
let index_count = usize::from(batch.index_count);
|
||||
let index_end = index_start.saturating_add(index_count);
|
||||
if index_end > model.indices.len() || index_count < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let batch_out_start = indices.len();
|
||||
let mut batch_valid = true;
|
||||
for &idx in &model.indices[index_start..index_end] {
|
||||
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
|
||||
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
|
||||
batch_valid = false;
|
||||
break;
|
||||
};
|
||||
let Some(pos) = model.positions.get(final_idx) else {
|
||||
batch_valid = false;
|
||||
break;
|
||||
};
|
||||
|
||||
let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
|
||||
mapped
|
||||
} else {
|
||||
let Ok(mapped) = u16::try_from(vertices.len()) else {
|
||||
index_overflow = true;
|
||||
batch_valid = false;
|
||||
break;
|
||||
};
|
||||
let uv = uv0
|
||||
.and_then(|uvs| uvs.get(final_idx))
|
||||
.copied()
|
||||
.map(|packed| {
|
||||
[
|
||||
packed[0] as f32 / DEFAULT_UV_SCALE,
|
||||
packed[1] as f32 / DEFAULT_UV_SCALE,
|
||||
]
|
||||
})
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
vertices.push(RenderVertex {
|
||||
position: *pos,
|
||||
uv0: uv,
|
||||
});
|
||||
index_remap.insert(final_idx, mapped);
|
||||
mapped
|
||||
};
|
||||
|
||||
indices.push(local_index);
|
||||
}
|
||||
|
||||
if !batch_valid {
|
||||
indices.truncate(batch_out_start);
|
||||
continue;
|
||||
}
|
||||
|
||||
batch_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
RenderMesh {
|
||||
vertices,
|
||||
indices,
|
||||
batch_count,
|
||||
index_overflow,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
|
||||
compute_bounds_impl(vertices.iter().copied())
|
||||
}
|
||||
|
||||
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
|
||||
compute_bounds_impl(vertices.iter().map(|v| v.position))
|
||||
}
|
||||
|
||||
fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
|
||||
where
|
||||
I: Iterator<Item = [f32; 3]>,
|
||||
{
|
||||
let first = positions.next()?;
|
||||
let mut min_v = first;
|
||||
let mut max_v = first;
|
||||
|
||||
for pos in positions {
|
||||
for i in 0..3 {
|
||||
if pos[i] < min_v[i] {
|
||||
min_v[i] = pos[i];
|
||||
}
|
||||
if pos[i] > max_v[i] {
|
||||
max_v[i] = pos[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some((min_v, max_v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,256 +0,0 @@
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use msh_core::parse_model_payload;
|
||||
use nres::Archive;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
fs::read(path)
|
||||
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_render_mesh_for_real_models() {
|
||||
let archives = nres_test_files();
|
||||
if archives.is_empty() {
|
||||
eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut models_checked = 0usize;
|
||||
let mut meshes_non_empty = 0usize;
|
||||
let mut bounds_non_empty = 0usize;
|
||||
|
||||
for archive_path in archives {
|
||||
let archive = Archive::open_path(&archive_path)
|
||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||
for entry in archive.entries() {
|
||||
if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
|
||||
continue;
|
||||
}
|
||||
models_checked += 1;
|
||||
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read model '{}' from {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to parse model '{}' from {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
let mesh = build_render_mesh(&model, 0, 0);
|
||||
if !mesh.indices.is_empty() {
|
||||
meshes_non_empty += 1;
|
||||
}
|
||||
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
|
||||
bounds_non_empty += 1;
|
||||
}
|
||||
for &index in &mesh.indices {
|
||||
assert!(
|
||||
usize::from(index) < mesh.vertices.len(),
|
||||
"index out of bounds for '{}' in {}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
);
|
||||
}
|
||||
for vertex in &mesh.vertices {
|
||||
assert!(
|
||||
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
|
||||
"UV must be finite for '{}' in {}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(models_checked > 0, "no MSH models found");
|
||||
assert!(
|
||||
meshes_non_empty > 0,
|
||||
"all generated render meshes are empty"
|
||||
);
|
||||
assert_eq!(
|
||||
meshes_non_empty, bounds_non_empty,
|
||||
"bounds must be available for every non-empty mesh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_bounds_handles_empty_and_non_empty() {
|
||||
assert!(compute_bounds(&[]).is_none());
|
||||
let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
|
||||
.expect("bounds expected");
|
||||
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
||||
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
|
||||
assert!(compute_bounds_for_mesh(&[]).is_none());
|
||||
let bounds = compute_bounds_for_mesh(&[
|
||||
RenderVertex {
|
||||
position: [1.0, 2.0, 3.0],
|
||||
uv0: [0.0, 0.0],
|
||||
},
|
||||
RenderVertex {
|
||||
position: [-2.0, 5.0, 0.5],
|
||||
uv0: [0.2, 0.3],
|
||||
},
|
||||
RenderVertex {
|
||||
position: [0.0, -1.0, 9.0],
|
||||
uv0: [1.0, 1.0],
|
||||
},
|
||||
])
|
||||
.expect("bounds expected");
|
||||
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
||||
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
||||
}
|
||||
|
||||
fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
|
||||
for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
|
||||
let node_off = node_index * 38;
|
||||
for i in 0..15 {
|
||||
let off = node_off + 8 + i * 2;
|
||||
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
}
|
||||
if let Some(slot_id) = slot_id {
|
||||
out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
|
||||
msh_core::Slot {
|
||||
tri_start: 0,
|
||||
tri_count: 0,
|
||||
batch_start,
|
||||
batch_count,
|
||||
aabb_min: [0.0; 3],
|
||||
aabb_max: [0.0; 3],
|
||||
sphere_center: [0.0; 3],
|
||||
sphere_radius: 0.0,
|
||||
opaque: [0; 5],
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
|
||||
msh_core::Batch {
|
||||
batch_flags: 0,
|
||||
material_index: 0,
|
||||
opaque4: 0,
|
||||
opaque6: 0,
|
||||
index_count,
|
||||
index_start,
|
||||
opaque14: 0,
|
||||
base_vertex,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_render_mesh_handles_empty_slot_model() {
|
||||
let model = msh_core::Model {
|
||||
node_stride: 38,
|
||||
node_count: 1,
|
||||
nodes_raw: nodes_with_slot_refs(&[None]),
|
||||
slots: Vec::new(),
|
||||
positions: vec![[0.0, 0.0, 0.0]],
|
||||
normals: None,
|
||||
uv0: None,
|
||||
indices: Vec::new(),
|
||||
batches: Vec::new(),
|
||||
node_names: None,
|
||||
};
|
||||
|
||||
let mesh = build_render_mesh(&model, 0, 0);
|
||||
assert!(mesh.vertices.is_empty());
|
||||
assert!(mesh.indices.is_empty());
|
||||
assert_eq!(mesh.batch_count, 0);
|
||||
assert_eq!(mesh.triangle_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_render_mesh_supports_multi_node_and_uv_scaling() {
|
||||
let model = msh_core::Model {
|
||||
node_stride: 38,
|
||||
node_count: 2,
|
||||
nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
|
||||
slots: vec![slot(0, 1), slot(1, 1)],
|
||||
positions: vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
[2.0, 1.0, 0.0],
|
||||
],
|
||||
normals: None,
|
||||
uv0: Some(vec![
|
||||
[1024, -1024],
|
||||
[512, 256],
|
||||
[0, 0],
|
||||
[1024, 1024],
|
||||
[2048, 1024],
|
||||
[1024, 0],
|
||||
]),
|
||||
indices: vec![0, 1, 2, 0, 1, 2],
|
||||
batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
|
||||
node_names: None,
|
||||
};
|
||||
|
||||
let mesh = build_render_mesh(&model, 0, 0);
|
||||
assert_eq!(mesh.batch_count, 2);
|
||||
assert_eq!(mesh.vertices.len(), 6);
|
||||
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
|
||||
assert_eq!(mesh.triangle_count(), 2);
|
||||
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
|
||||
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
|
||||
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
|
||||
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_render_mesh_deduplicates_shared_vertices() {
|
||||
let model = msh_core::Model {
|
||||
node_stride: 38,
|
||||
node_count: 1,
|
||||
nodes_raw: nodes_with_slot_refs(&[Some(0)]),
|
||||
slots: vec![slot(0, 1)],
|
||||
positions: vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
],
|
||||
normals: None,
|
||||
uv0: None,
|
||||
indices: vec![0, 1, 2, 2, 1, 3],
|
||||
batches: vec![batch(0, 6, 0)],
|
||||
node_names: None,
|
||||
};
|
||||
|
||||
let mesh = build_render_mesh(&model, 0, 0);
|
||||
assert_eq!(mesh.vertices.len(), 4);
|
||||
assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
|
||||
assert_eq!(mesh.triangle_count(), 2);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "render-demo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
msh-core = { path = "../msh-core" }
|
||||
nres = { path = "../nres" }
|
||||
render-core = { path = "../render-core" }
|
||||
texm = { path = "../texm" }
|
||||
glow = { version = "0.17", optional = true }
|
||||
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||
|
||||
[[bin]]
|
||||
name = "parkan-render-demo"
|
||||
path = "src/main.rs"
|
||||
required-features = ["demo"]
|
||||
@@ -1,84 +0,0 @@
|
||||
# render-demo
|
||||
|
||||
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
|
||||
|
||||
## Назначение
|
||||
|
||||
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
|
||||
- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
|
||||
- Служить минимальным reference-приложением.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cargo run -p render-demo --features demo -- \
|
||||
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||
--model "A_L_01.msh" \
|
||||
--lod 0 \
|
||||
--group 0
|
||||
```
|
||||
|
||||
### macOS prerequisites
|
||||
|
||||
Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
|
||||
|
||||
```bash
|
||||
brew install sdl2 pkg-config
|
||||
```
|
||||
|
||||
После этого запускайте той же командой `cargo run ... --features demo`.
|
||||
|
||||
Параметры:
|
||||
|
||||
- `--archive` (обязательный): NRes-архив с `.msh` entry.
|
||||
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
|
||||
- `--lod` (опционально, default `0`).
|
||||
- `--group` (опционально, default `0`).
|
||||
- `--width`, `--height` (опционально, default `1280x720`).
|
||||
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
|
||||
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
|
||||
- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
|
||||
- `--texture <name>`: явное имя `Texm` (override авто-резолва).
|
||||
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
|
||||
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
|
||||
- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
|
||||
- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
|
||||
|
||||
## Авто-резолв текстуры
|
||||
|
||||
Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
|
||||
|
||||
1. `model.msh -> model.wea` (первый wear-материал),
|
||||
2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
|
||||
3. первая непустая `textureName` фаза материала,
|
||||
4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
|
||||
|
||||
## Детерминированный снимок кадра
|
||||
|
||||
Для parity-проверок используется headless-сценарий с фиксированными параметрами:
|
||||
|
||||
```bash
|
||||
cargo run -p render-demo --features demo -- \
|
||||
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||
--model "A_L_01.msh" \
|
||||
--lod 0 \
|
||||
--group 0 \
|
||||
--width 1280 \
|
||||
--height 720 \
|
||||
--angle 0.0 \
|
||||
--capture "target/render-parity/current/animals_a_l_01.png"
|
||||
```
|
||||
|
||||
Явный выбор текстуры:
|
||||
|
||||
```bash
|
||||
cargo run -p render-demo --features demo -- \
|
||||
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||
--model "A_L_01.msh" \
|
||||
--texture "PG09.0"
|
||||
```
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Используется только базовая texture-фаза (без полной material/fx анимации).
|
||||
- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).
|
||||
@@ -1,4 +0,0 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
println!("cargo:rustc-link-lib=advapi32");
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use msh_core::{parse_model_payload, Model};
|
||||
use nres::{Archive, EntryRef};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use texm::{decode_mip_rgba8, parse_texm};
|
||||
|
||||
const WEAR_KIND: u32 = 0x5241_4557;
|
||||
const MAT0_KIND: u32 = 0x3054_414D;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Nres(nres::error::Error),
|
||||
Msh(msh_core::error::Error),
|
||||
Texm(texm::error::Error),
|
||||
Io(std::io::Error),
|
||||
NoMshEntries,
|
||||
ModelNotFound(String),
|
||||
NoTexmEntries,
|
||||
TextureNotFound(String),
|
||||
MaterialNotFound(String),
|
||||
WearNotFound(String),
|
||||
InvalidWear(String),
|
||||
InvalidMaterial(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Nres(err) => write!(f, "{err}"),
|
||||
Self::Msh(err) => write!(f, "{err}"),
|
||||
Self::Texm(err) => write!(f, "{err}"),
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
|
||||
Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
|
||||
Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
|
||||
Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
|
||||
Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
|
||||
Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
|
||||
Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
|
||||
Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Nres(err) => Some(err),
|
||||
Self::Msh(err) => Some(err),
|
||||
Self::Texm(err) => Some(err),
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nres::error::Error> for Error {
|
||||
fn from(value: nres::error::Error) -> Self {
|
||||
Self::Nres(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<msh_core::error::Error> for Error {
|
||||
fn from(value: msh_core::error::Error) -> Self {
|
||||
Self::Msh(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<texm::error::Error> for Error {
|
||||
fn from(value: texm::error::Error) -> Self {
|
||||
Self::Texm(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadedModel {
|
||||
pub name: String,
|
||||
pub model: Model,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadedTexture {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba8: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn load_model_with_name_from_archive(
|
||||
path: &Path,
|
||||
model_name: Option<&str>,
|
||||
) -> Result<LoadedModel> {
|
||||
let archive = Archive::open_path(path)?;
|
||||
let mut msh_entries = Vec::new();
|
||||
for entry in archive.entries() {
|
||||
if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
|
||||
msh_entries.push((entry.id, entry.meta.name.clone()));
|
||||
}
|
||||
}
|
||||
if msh_entries.is_empty() {
|
||||
return Err(Error::NoMshEntries);
|
||||
}
|
||||
|
||||
let target_id = if let Some(name) = model_name {
|
||||
msh_entries
|
||||
.iter()
|
||||
.find(|(_, n)| n.eq_ignore_ascii_case(name))
|
||||
.map(|(id, _)| *id)
|
||||
.ok_or_else(|| Error::ModelNotFound(name.to_string()))?
|
||||
} else {
|
||||
msh_entries[0].0
|
||||
};
|
||||
|
||||
let target_name = archive
|
||||
.get(target_id)
|
||||
.map(|entry| entry.meta.name.clone())
|
||||
.unwrap_or_else(|| String::from("<unknown>"));
|
||||
let payload = archive.read(target_id)?;
|
||||
Ok(LoadedModel {
|
||||
name: target_name,
|
||||
model: parse_model_payload(payload.as_slice())?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
|
||||
Ok(load_model_with_name_from_archive(path, model_name)?.model)
|
||||
}
|
||||
|
||||
pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
|
||||
let archive = Archive::open_path(path)?;
|
||||
if let Some(name) = texture_name {
|
||||
return load_texture_from_archive_by_name(&archive, name);
|
||||
}
|
||||
|
||||
let mut texm_entries = archive
|
||||
.entries()
|
||||
.filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
|
||||
.collect::<Vec<_>>();
|
||||
if texm_entries.is_empty() {
|
||||
return Err(Error::NoTexmEntries);
|
||||
}
|
||||
texm_entries.sort_by(|a, b| {
|
||||
a.meta
|
||||
.name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.meta.name.to_ascii_lowercase())
|
||||
});
|
||||
let first = texm_entries[0];
|
||||
decode_texture_entry(&archive, first)
|
||||
}
|
||||
|
||||
pub fn resolve_texture_for_model(
|
||||
model_archive_path: &Path,
|
||||
model_entry_name: &str,
|
||||
texture_name_override: Option<&str>,
|
||||
textures_archive_override: Option<&Path>,
|
||||
material_archive_override: Option<&Path>,
|
||||
wear_entry_override: Option<&str>,
|
||||
) -> Result<Option<LoadedTexture>> {
|
||||
if let Some(name) = texture_name_override {
|
||||
return load_texture_by_name_from_candidate_archives(
|
||||
name,
|
||||
candidate_texture_archives(model_archive_path, textures_archive_override),
|
||||
)
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
let wear_entry_name = if let Some(name) = wear_entry_override {
|
||||
name.to_string()
|
||||
} else {
|
||||
derive_wear_entry_name(model_entry_name).ok_or_else(|| {
|
||||
Error::WearNotFound(format!(
|
||||
"cannot derive WEAR name from model '{model_entry_name}'"
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
let model_archive = Archive::open_path(model_archive_path)?;
|
||||
let wear_materials = parse_wear_material_names(
|
||||
read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
|
||||
.0
|
||||
.as_slice(),
|
||||
)?;
|
||||
let Some(primary_material) = wear_materials.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let material_path = if let Some(path) = material_archive_override {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
sibling_archive_path(model_archive_path, "material.lib")
|
||||
.ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
|
||||
};
|
||||
let material_archive = Archive::open_path(&material_path)?;
|
||||
let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
|
||||
let material_payload = material_archive.read(material_entry.id)?.into_owned();
|
||||
let texture_name =
|
||||
parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
|
||||
let Some(texture_name) = texture_name else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let texture = load_texture_by_name_from_candidate_archives(
|
||||
&texture_name,
|
||||
candidate_texture_archives(model_archive_path, textures_archive_override),
|
||||
)?;
|
||||
Ok(Some(texture))
|
||||
}
|
||||
|
||||
fn load_texture_by_name_from_candidate_archives(
|
||||
texture_name: &str,
|
||||
archives: Vec<PathBuf>,
|
||||
) -> Result<LoadedTexture> {
|
||||
let mut last_not_found = None;
|
||||
for archive_path in archives {
|
||||
if !archive_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let archive = Archive::open_path(&archive_path)?;
|
||||
match load_texture_from_archive_by_name(&archive, texture_name) {
|
||||
Ok(texture) => return Ok(texture),
|
||||
Err(Error::TextureNotFound(name)) => {
|
||||
last_not_found = Some(name);
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::TextureNotFound(
|
||||
last_not_found.unwrap_or_else(|| texture_name.to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
fn candidate_texture_archives(
|
||||
model_archive_path: &Path,
|
||||
textures_archive_override: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
if let Some(path) = textures_archive_override {
|
||||
return vec![path.to_path_buf()];
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
|
||||
out.push(path);
|
||||
}
|
||||
if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
|
||||
out.push(path);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
|
||||
let parent = model_archive_path.parent()?;
|
||||
Some(parent.join(name))
|
||||
}
|
||||
|
||||
fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
|
||||
let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
|
||||
Some(format!("{stem}.wea"))
|
||||
}
|
||||
|
||||
fn read_entry_by_name_kind(
|
||||
archive: &Archive,
|
||||
name: &str,
|
||||
expected_kind: u32,
|
||||
) -> Result<(Vec<u8>, String)> {
|
||||
let Some(id) = archive.find(name) else {
|
||||
return Err(Error::WearNotFound(name.to_string()));
|
||||
};
|
||||
let Some(entry) = archive.get(id) else {
|
||||
return Err(Error::WearNotFound(name.to_string()));
|
||||
};
|
||||
if entry.meta.kind != expected_kind {
|
||||
return Err(Error::WearNotFound(name.to_string()));
|
||||
}
|
||||
let payload = archive.read(id)?.into_owned();
|
||||
Ok((payload, entry.meta.name.clone()))
|
||||
}
|
||||
|
||||
fn find_material_entry_with_fallback<'a>(
|
||||
archive: &'a Archive,
|
||||
requested_name: &str,
|
||||
) -> Result<EntryRef<'a>> {
|
||||
if let Some(id) = archive.find(requested_name) {
|
||||
if let Some(entry) = archive.get(id) {
|
||||
if entry.meta.kind == MAT0_KIND {
|
||||
return Ok(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = archive.find("DEFAULT") {
|
||||
if let Some(entry) = archive.get(id) {
|
||||
if entry.meta.kind == MAT0_KIND {
|
||||
return Ok(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
|
||||
return Err(Error::MaterialNotFound(requested_name.to_string()));
|
||||
};
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
|
||||
let text = decode_cp1251(payload).replace('\r', "");
|
||||
let mut lines = text.lines();
|
||||
let Some(first) = lines.next() else {
|
||||
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
|
||||
};
|
||||
let count = first
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
|
||||
if count == 0 {
|
||||
return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
|
||||
}
|
||||
|
||||
let mut materials = Vec::with_capacity(count);
|
||||
for idx in 0..count {
|
||||
let Some(line) = lines.next() else {
|
||||
return Err(Error::InvalidWear(format!(
|
||||
"missing material line {idx} of {count}"
|
||||
)));
|
||||
};
|
||||
let mut parts = line.split_whitespace();
|
||||
let _legacy = parts
|
||||
.next()
|
||||
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
|
||||
let name = parts
|
||||
.next()
|
||||
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
|
||||
materials.push(name.to_string());
|
||||
}
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
|
||||
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
|
||||
if payload.len() < 4 {
|
||||
return Err(Error::InvalidMaterial(String::from(
|
||||
"MAT0 payload is too small for header",
|
||||
)));
|
||||
}
|
||||
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
if phase_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = 4usize;
|
||||
if attr2 >= 2 {
|
||||
offset = offset
|
||||
.checked_add(2)
|
||||
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||
}
|
||||
if attr2 >= 3 {
|
||||
offset = offset
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||
}
|
||||
if attr2 >= 4 {
|
||||
offset = offset
|
||||
.checked_add(4)
|
||||
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||
}
|
||||
|
||||
for phase in 0..phase_count {
|
||||
let phase_off = offset
|
||||
.checked_add(phase.checked_mul(34).ok_or_else(|| {
|
||||
Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
|
||||
})?)
|
||||
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
|
||||
let phase_end = phase_off
|
||||
.checked_add(34)
|
||||
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
|
||||
let Some(rec) = payload.get(phase_off..phase_end) else {
|
||||
return Err(Error::InvalidMaterial(format!(
|
||||
"MAT0 phase {phase} is out of bounds"
|
||||
)));
|
||||
};
|
||||
let name_raw = &rec[18..34];
|
||||
let name_end = name_raw
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(name_raw.len());
|
||||
let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
|
||||
if !name.is_empty() {
|
||||
return Ok(Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
|
||||
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
|
||||
let Some(id) = archive.find(name) else {
|
||||
return Err(Error::TextureNotFound(name.to_string()));
|
||||
};
|
||||
let Some(entry) = archive.get(id) else {
|
||||
return Err(Error::TextureNotFound(name.to_string()));
|
||||
};
|
||||
if entry.meta.kind != texm::TEXM_MAGIC {
|
||||
return Err(Error::TextureNotFound(name.to_string()));
|
||||
}
|
||||
decode_texture_entry(archive, entry)
|
||||
}
|
||||
|
||||
fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
|
||||
let payload = archive.read(entry.id)?.into_owned();
|
||||
let parsed = parse_texm(&payload)?;
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
|
||||
Ok(LoadedTexture {
|
||||
name: entry.meta.name.clone(),
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
rgba8: decoded.rgba8,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn archive_with_msh() -> Option<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
for path in files {
|
||||
let Ok(bytes) = fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
if bytes.get(0..4) != Some(b"NRes") {
|
||||
continue;
|
||||
}
|
||||
let Ok(archive) = Archive::open_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
if archive
|
||||
.entries()
|
||||
.any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn game_root() -> Option<PathBuf> {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("Parkan - Iron Strategy");
|
||||
if path.is_dir() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_model_from_real_archive() {
|
||||
let Some(path) = archive_with_msh() else {
|
||||
eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
|
||||
return;
|
||||
};
|
||||
let model = load_model_from_archive(&path, None)
|
||||
.unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
|
||||
assert!(model.node_count > 0);
|
||||
assert!(!model.positions.is_empty());
|
||||
assert!(!model.indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_texture_for_real_model_via_wear_and_material() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!(
|
||||
"skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let archive = root.join("animals.rlb");
|
||||
if !archive.is_file() {
|
||||
eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to load model A_L_01.msh from {}: {err:?}",
|
||||
archive.display()
|
||||
)
|
||||
});
|
||||
let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
|
||||
.unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
|
||||
.expect("texture must be resolved for A_L_01.msh");
|
||||
assert!(texture.width > 0 && texture.height > 0);
|
||||
assert_eq!(
|
||||
texture.rgba8.len(),
|
||||
usize::try_from(texture.width)
|
||||
.ok()
|
||||
.and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
|
||||
.unwrap_or(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_first_texture_from_real_archive() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping load_first_texture_from_real_archive: no game root");
|
||||
return;
|
||||
};
|
||||
let archive = root.join("textures.lib");
|
||||
if !archive.is_file() {
|
||||
eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
|
||||
return;
|
||||
}
|
||||
let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to load first texture from {}: {err:?}",
|
||||
archive.display()
|
||||
)
|
||||
});
|
||||
assert!(texture.width > 0 && texture.height > 0);
|
||||
assert!(!texture.rgba8.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wear_material_names_parses_counted_lines() {
|
||||
let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
|
||||
let materials =
|
||||
parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
|
||||
assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_wear_material_names_rejects_invalid_payload() {
|
||||
let payload = b"2\n0 ONLY_ONE\n";
|
||||
assert!(matches!(
|
||||
parse_wear_material_names(payload),
|
||||
Err(Error::InvalidWear(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
|
||||
let mut payload = vec![0u8; 4 + 10 + 34];
|
||||
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||
// attr2=4 adds 10 bytes before phase table
|
||||
let name = b"TEX_MAIN";
|
||||
payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
|
||||
|
||||
let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
|
||||
.expect("failed to parse MAT0 payload with attr2=4");
|
||||
assert_eq!(parsed, Some("TEX_MAIN".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
|
||||
let mut payload = vec![0u8; 4 + 34];
|
||||
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||
payload[4 + 18] = 0xC0; // 'А' in CP1251
|
||||
|
||||
let parsed =
|
||||
parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
|
||||
assert_eq!(parsed, Some("А".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,997 +0,0 @@
|
||||
use glow::HasContext as _;
|
||||
use render_core::{build_render_mesh, compute_bounds_for_mesh};
|
||||
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Args {
|
||||
archive: PathBuf,
|
||||
model: Option<String>,
|
||||
lod: usize,
|
||||
group: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fov_deg: f32,
|
||||
capture: Option<PathBuf>,
|
||||
angle: Option<f32>,
|
||||
spin_rate: f32,
|
||||
texture: Option<String>,
|
||||
texture_archive: Option<PathBuf>,
|
||||
material_archive: Option<PathBuf>,
|
||||
wear: Option<String>,
|
||||
no_texture: bool,
|
||||
}
|
||||
|
||||
struct GpuTexture {
|
||||
handle: glow::NativeTexture,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum GlBackend {
|
||||
Gles2,
|
||||
Core33,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut archive = None;
|
||||
let mut model = None;
|
||||
let mut lod = 0usize;
|
||||
let mut group = 0usize;
|
||||
let mut width = 1280u32;
|
||||
let mut height = 720u32;
|
||||
let mut fov_deg = 60.0f32;
|
||||
let mut capture = None;
|
||||
let mut angle = None;
|
||||
let mut spin_rate = 0.35f32;
|
||||
let mut texture = None;
|
||||
let mut texture_archive = None;
|
||||
let mut material_archive = None;
|
||||
let mut wear = None;
|
||||
let mut no_texture = false;
|
||||
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--archive" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --archive"))?;
|
||||
archive = Some(PathBuf::from(value));
|
||||
}
|
||||
"--model" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --model"))?;
|
||||
model = Some(value);
|
||||
}
|
||||
"--lod" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --lod"))?;
|
||||
lod = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| String::from("invalid --lod value"))?;
|
||||
}
|
||||
"--group" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --group"))?;
|
||||
group = value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| String::from("invalid --group value"))?;
|
||||
}
|
||||
"--width" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --width"))?;
|
||||
width = value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| String::from("invalid --width value"))?;
|
||||
if width == 0 {
|
||||
return Err(String::from("--width must be > 0"));
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --height"))?;
|
||||
height = value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| String::from("invalid --height value"))?;
|
||||
if height == 0 {
|
||||
return Err(String::from("--height must be > 0"));
|
||||
}
|
||||
}
|
||||
"--fov" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||
fov_deg = value
|
||||
.parse::<f32>()
|
||||
.map_err(|_| String::from("invalid --fov value"))?;
|
||||
if !(1.0..=179.0).contains(&fov_deg) {
|
||||
return Err(String::from("--fov must be in range [1, 179]"));
|
||||
}
|
||||
}
|
||||
"--capture" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --capture"))?;
|
||||
capture = Some(PathBuf::from(value));
|
||||
}
|
||||
"--angle" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --angle"))?;
|
||||
angle = Some(
|
||||
value
|
||||
.parse::<f32>()
|
||||
.map_err(|_| String::from("invalid --angle value"))?,
|
||||
);
|
||||
}
|
||||
"--spin-rate" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --spin-rate"))?;
|
||||
spin_rate = value
|
||||
.parse::<f32>()
|
||||
.map_err(|_| String::from("invalid --spin-rate value"))?;
|
||||
}
|
||||
"--texture" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --texture"))?;
|
||||
texture = Some(value);
|
||||
}
|
||||
"--texture-archive" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --texture-archive"))?;
|
||||
texture_archive = Some(PathBuf::from(value));
|
||||
}
|
||||
"--material-archive" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --material-archive"))?;
|
||||
material_archive = Some(PathBuf::from(value));
|
||||
}
|
||||
"--wear" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --wear"))?;
|
||||
wear = Some(value);
|
||||
}
|
||||
"--no-texture" => {
|
||||
no_texture = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
|
||||
Ok(Args {
|
||||
archive,
|
||||
model,
|
||||
lod,
|
||||
group,
|
||||
width,
|
||||
height,
|
||||
fov_deg,
|
||||
capture,
|
||||
angle,
|
||||
spin_rate,
|
||||
texture,
|
||||
texture_archive,
|
||||
material_archive,
|
||||
wear,
|
||||
no_texture,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
|
||||
);
|
||||
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
||||
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = match parse_args() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = run(args) {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: Args) -> Result<(), String> {
|
||||
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to load model from archive {}: {err}",
|
||||
args.archive.display()
|
||||
)
|
||||
})?;
|
||||
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
|
||||
if mesh.indices.is_empty() {
|
||||
return Err(format!(
|
||||
"model has no renderable triangles for lod={} group={}",
|
||||
args.lod, args.group
|
||||
));
|
||||
}
|
||||
if mesh.index_overflow {
|
||||
eprintln!(
|
||||
"warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
|
||||
);
|
||||
}
|
||||
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
|
||||
return Err(String::from("failed to compute mesh bounds"));
|
||||
};
|
||||
|
||||
let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
|
||||
if let Some(tex) = resolved_texture.as_ref() {
|
||||
println!(
|
||||
"resolved texture '{}' ({}x{})",
|
||||
tex.name, tex.width, tex.height
|
||||
);
|
||||
} else {
|
||||
println!("texture path disabled or unresolved; rendering with fallback color");
|
||||
}
|
||||
|
||||
let center = [
|
||||
0.5 * (bounds_min[0] + bounds_max[0]),
|
||||
0.5 * (bounds_min[1] + bounds_max[1]),
|
||||
0.5 * (bounds_min[2] + bounds_max[2]),
|
||||
];
|
||||
let extent = [
|
||||
bounds_max[0] - bounds_min[0],
|
||||
bounds_max[1] - bounds_min[1],
|
||||
bounds_max[2] - bounds_min[2],
|
||||
];
|
||||
let radius =
|
||||
(extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
|
||||
let camera_distance = (radius * 2.5).max(2.0);
|
||||
|
||||
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||
let video = sdl
|
||||
.video()
|
||||
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||
|
||||
let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
|
||||
let _ = if args.capture.is_some() {
|
||||
video.gl_set_swap_interval(0)
|
||||
} else {
|
||||
video.gl_set_swap_interval(1)
|
||||
};
|
||||
|
||||
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||
for vertex in &mesh.vertices {
|
||||
vertex_data.push(vertex.position[0]);
|
||||
vertex_data.push(vertex.position[1]);
|
||||
vertex_data.push(vertex.position[2]);
|
||||
vertex_data.push(vertex.uv0[0]);
|
||||
vertex_data.push(vertex.uv0[1]);
|
||||
}
|
||||
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
|
||||
let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
|
||||
|
||||
let gl = unsafe {
|
||||
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||
};
|
||||
|
||||
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
||||
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
|
||||
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||
|
||||
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||
let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||
unsafe {
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||
}
|
||||
let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
|
||||
|
||||
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
|
||||
Some(unsafe { create_texture(&gl, texture)? })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = if let Some(capture_path) = args.capture.as_ref() {
|
||||
run_capture(
|
||||
&gl,
|
||||
program,
|
||||
u_mvp.as_ref(),
|
||||
u_use_tex.as_ref(),
|
||||
u_tex.as_ref(),
|
||||
a_pos,
|
||||
a_uv,
|
||||
vbo,
|
||||
ebo,
|
||||
vao,
|
||||
gpu_texture.as_ref(),
|
||||
mesh.indices.len(),
|
||||
&args,
|
||||
center,
|
||||
camera_distance,
|
||||
capture_path,
|
||||
)
|
||||
} else {
|
||||
run_interactive(
|
||||
&sdl,
|
||||
&mut window,
|
||||
&gl,
|
||||
program,
|
||||
u_mvp.as_ref(),
|
||||
u_use_tex.as_ref(),
|
||||
u_tex.as_ref(),
|
||||
a_pos,
|
||||
a_uv,
|
||||
vbo,
|
||||
ebo,
|
||||
vao,
|
||||
gpu_texture.as_ref(),
|
||||
mesh.indices.len(),
|
||||
&args,
|
||||
center,
|
||||
camera_distance,
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
if let Some(texture) = gpu_texture {
|
||||
gl.delete_texture(texture.handle);
|
||||
}
|
||||
if let Some(vao) = vao {
|
||||
gl.delete_vertex_array(vao);
|
||||
}
|
||||
gl.delete_buffer(ebo);
|
||||
gl.delete_buffer(vbo);
|
||||
gl.delete_program(program);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn create_window_and_context(
|
||||
video: &sdl2::VideoSubsystem,
|
||||
args: &Args,
|
||||
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||
let candidates = [
|
||||
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||
];
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (backend, profile, major, minor) in candidates {
|
||||
{
|
||||
let gl_attr = video.gl_attr();
|
||||
gl_attr.set_context_profile(profile);
|
||||
gl_attr.set_context_version(major, minor);
|
||||
gl_attr.set_depth_size(24);
|
||||
gl_attr.set_double_buffer(true);
|
||||
}
|
||||
|
||||
let mut window_builder = video.window(
|
||||
"Parkan Render Demo (SDL2 + OpenGL)",
|
||||
args.width,
|
||||
args.height,
|
||||
);
|
||||
window_builder.opengl();
|
||||
if args.capture.is_some() {
|
||||
window_builder.hidden();
|
||||
} else {
|
||||
window_builder.resizable();
|
||||
}
|
||||
|
||||
let window = match window_builder.build() {
|
||||
Ok(window) => window,
|
||||
Err(err) => {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let gl_ctx = match window.gl_create_context() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok((window, gl_ctx, backend));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"failed to create OpenGL context. Attempts: {}",
|
||||
errors.join(" | ")
|
||||
))
|
||||
}
|
||||
|
||||
unsafe fn create_vertex_layout_if_needed(
|
||||
gl: &glow::Context,
|
||||
backend: GlBackend,
|
||||
vbo: glow::NativeBuffer,
|
||||
ebo: glow::NativeBuffer,
|
||||
a_pos: u32,
|
||||
a_uv: u32,
|
||||
) -> Result<Option<glow::NativeVertexArray>, String> {
|
||||
if backend != GlBackend::Core33 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
|
||||
gl.bind_vertex_array(Some(vao));
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||
gl.enable_vertex_attrib_array(a_pos);
|
||||
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||
gl.enable_vertex_attrib_array(a_uv);
|
||||
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||
gl.bind_vertex_array(None);
|
||||
Ok(Some(vao))
|
||||
}
|
||||
|
||||
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
|
||||
if args.no_texture {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match resolve_texture_for_model(
|
||||
&args.archive,
|
||||
model_name,
|
||||
args.texture.as_deref(),
|
||||
args.texture_archive.as_deref(),
|
||||
args.material_archive.as_deref(),
|
||||
args.wear.as_deref(),
|
||||
) {
|
||||
Ok(texture) => Ok(texture),
|
||||
Err(err) => {
|
||||
if args.texture.is_some()
|
||||
|| args.texture_archive.is_some()
|
||||
|| args.material_archive.is_some()
|
||||
|| args.wear.is_some()
|
||||
{
|
||||
Err(format!("failed to resolve texture: {err}"))
|
||||
} else {
|
||||
eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_texture(
|
||||
gl: &glow::Context,
|
||||
texture: &LoadedTexture,
|
||||
) -> Result<GpuTexture, String> {
|
||||
let handle = gl.create_texture().map_err(|e| e.to_string())?;
|
||||
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MIN_FILTER,
|
||||
glow::LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MAG_FILTER,
|
||||
glow::LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
|
||||
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
|
||||
gl.tex_image_2d(
|
||||
glow::TEXTURE_2D,
|
||||
0,
|
||||
glow::RGBA as i32,
|
||||
texture.width.min(i32::MAX as u32) as i32,
|
||||
texture.height.min(i32::MAX as u32) as i32,
|
||||
0,
|
||||
glow::RGBA,
|
||||
glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
|
||||
);
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
Ok(GpuTexture { handle })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_capture(
|
||||
gl: &glow::Context,
|
||||
program: glow::NativeProgram,
|
||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||
u_tex: Option<&glow::NativeUniformLocation>,
|
||||
a_pos: u32,
|
||||
a_uv: u32,
|
||||
vbo: glow::NativeBuffer,
|
||||
ebo: glow::NativeBuffer,
|
||||
vao: Option<glow::NativeVertexArray>,
|
||||
texture: Option<&GpuTexture>,
|
||||
index_count: usize,
|
||||
args: &Args,
|
||||
center: [f32; 3],
|
||||
camera_distance: f32,
|
||||
capture_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let angle = args.angle.unwrap_or(0.0);
|
||||
let mvp = compute_mvp(
|
||||
args.width,
|
||||
args.height,
|
||||
args.fov_deg,
|
||||
center,
|
||||
camera_distance,
|
||||
angle,
|
||||
);
|
||||
unsafe {
|
||||
draw_frame(
|
||||
gl,
|
||||
program,
|
||||
u_mvp,
|
||||
u_use_tex,
|
||||
u_tex,
|
||||
a_pos,
|
||||
a_uv,
|
||||
vbo,
|
||||
ebo,
|
||||
vao,
|
||||
texture,
|
||||
index_count,
|
||||
args.width,
|
||||
args.height,
|
||||
&mvp,
|
||||
);
|
||||
}
|
||||
let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
|
||||
flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
|
||||
save_png(capture_path, args.width, args.height, rgba)?;
|
||||
println!("captured frame to {}", capture_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_interactive(
|
||||
sdl: &sdl2::Sdl,
|
||||
window: &mut sdl2::video::Window,
|
||||
gl: &glow::Context,
|
||||
program: glow::NativeProgram,
|
||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||
u_tex: Option<&glow::NativeUniformLocation>,
|
||||
a_pos: u32,
|
||||
a_uv: u32,
|
||||
vbo: glow::NativeBuffer,
|
||||
ebo: glow::NativeBuffer,
|
||||
vao: Option<glow::NativeVertexArray>,
|
||||
texture: Option<&GpuTexture>,
|
||||
index_count: usize,
|
||||
args: &Args,
|
||||
center: [f32; 3],
|
||||
camera_distance: f32,
|
||||
) -> Result<(), String> {
|
||||
let mut events = sdl
|
||||
.event_pump()
|
||||
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||
let start = Instant::now();
|
||||
let mut fps_window_start = Instant::now();
|
||||
let mut fps_frames: u32 = 0;
|
||||
let mut fps_printed = false;
|
||||
let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
|
||||
|
||||
'main_loop: loop {
|
||||
for event in events.poll_iter() {
|
||||
match event {
|
||||
sdl2::event::Event::Quit { .. } => break 'main_loop,
|
||||
sdl2::event::Event::KeyDown {
|
||||
keycode: Some(sdl2::keyboard::Keycode::Escape),
|
||||
..
|
||||
} => break 'main_loop,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (w, h) = window.size();
|
||||
let angle = args
|
||||
.angle
|
||||
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
|
||||
let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
|
||||
|
||||
unsafe {
|
||||
draw_frame(
|
||||
gl,
|
||||
program,
|
||||
u_mvp,
|
||||
u_use_tex,
|
||||
u_tex,
|
||||
a_pos,
|
||||
a_uv,
|
||||
vbo,
|
||||
ebo,
|
||||
vao,
|
||||
texture,
|
||||
index_count,
|
||||
w,
|
||||
h,
|
||||
&mvp,
|
||||
);
|
||||
}
|
||||
window.gl_swap_window();
|
||||
|
||||
fps_frames = fps_frames.saturating_add(1);
|
||||
let elapsed = fps_window_start.elapsed();
|
||||
if elapsed >= Duration::from_millis(500) {
|
||||
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||
let _ = window.set_title(&format!(
|
||||
"{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
|
||||
));
|
||||
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||
let _ = std::io::stdout().flush();
|
||||
fps_printed = true;
|
||||
fps_frames = 0;
|
||||
fps_window_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
if fps_printed {
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_mvp(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fov_deg: f32,
|
||||
center: [f32; 3],
|
||||
camera_distance: f32,
|
||||
angle_rad: f32,
|
||||
) -> [f32; 16] {
|
||||
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
|
||||
let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
|
||||
let view = mat4_translation(0.0, 0.0, -camera_distance);
|
||||
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
||||
let rot = mat4_rotation_y(angle_rad);
|
||||
let model_m = mat4_mul(&rot, ¢er_shift);
|
||||
let vp = mat4_mul(&view, &model_m);
|
||||
mat4_mul(&proj, &vp)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn draw_frame(
|
||||
gl: &glow::Context,
|
||||
program: glow::NativeProgram,
|
||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||
u_tex: Option<&glow::NativeUniformLocation>,
|
||||
a_pos: u32,
|
||||
a_uv: u32,
|
||||
vbo: glow::NativeBuffer,
|
||||
ebo: glow::NativeBuffer,
|
||||
vao: Option<glow::NativeVertexArray>,
|
||||
texture: Option<&GpuTexture>,
|
||||
index_count: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
mvp: &[f32; 16],
|
||||
) {
|
||||
gl.viewport(
|
||||
0,
|
||||
0,
|
||||
width.min(i32::MAX as u32) as i32,
|
||||
height.min(i32::MAX as u32) as i32,
|
||||
);
|
||||
gl.enable(glow::DEPTH_TEST);
|
||||
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||
|
||||
gl.use_program(Some(program));
|
||||
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||
|
||||
let texture_enabled = texture.is_some();
|
||||
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
|
||||
if let Some(tex) = texture {
|
||||
gl.active_texture(glow::TEXTURE0);
|
||||
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
|
||||
gl.uniform_1_i32(u_tex, 0);
|
||||
} else {
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
}
|
||||
|
||||
if let Some(vao) = vao {
|
||||
gl.bind_vertex_array(Some(vao));
|
||||
gl.draw_elements(
|
||||
glow::TRIANGLES,
|
||||
index_count.min(i32::MAX as usize) as i32,
|
||||
glow::UNSIGNED_SHORT,
|
||||
0,
|
||||
);
|
||||
gl.bind_vertex_array(None);
|
||||
} else {
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||
gl.enable_vertex_attrib_array(a_pos);
|
||||
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||
gl.enable_vertex_attrib_array(a_uv);
|
||||
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||
gl.draw_elements(
|
||||
glow::TRIANGLES,
|
||||
index_count.min(i32::MAX as usize) as i32,
|
||||
glow::UNSIGNED_SHORT,
|
||||
0,
|
||||
);
|
||||
gl.disable_vertex_attrib_array(a_uv);
|
||||
gl.disable_vertex_attrib_array(a_pos);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||
}
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
gl.use_program(None);
|
||||
}
|
||||
|
||||
unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
|
||||
let pixel_count = usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
|
||||
.ok_or_else(|| String::from("frame dimensions are too large"))?;
|
||||
let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
|
||||
gl.read_pixels(
|
||||
0,
|
||||
0,
|
||||
width.min(i32::MAX as u32) as i32,
|
||||
height.min(i32::MAX as u32) as i32,
|
||||
glow::RGBA,
|
||||
glow::UNSIGNED_BYTE,
|
||||
glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
|
||||
);
|
||||
Ok(pixels)
|
||||
}
|
||||
|
||||
fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
|
||||
let stride = width.saturating_mul(4);
|
||||
if stride == 0 {
|
||||
return;
|
||||
}
|
||||
for y in 0..(height / 2) {
|
||||
let top = y * stride;
|
||||
let bottom = (height - 1 - y) * stride;
|
||||
for i in 0..stride {
|
||||
rgba.swap(top + i, bottom + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create output directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let image = image::RgbaImage::from_raw(width, height, rgba)
|
||||
.ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
|
||||
image
|
||||
.save(path)
|
||||
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
|
||||
}
|
||||
|
||||
unsafe fn create_program(
|
||||
gl: &glow::Context,
|
||||
backend: GlBackend,
|
||||
) -> Result<glow::NativeProgram, String> {
|
||||
let (vs_src, fs_src) = match backend {
|
||||
GlBackend::Gles2 => (
|
||||
r#"
|
||||
attribute vec3 a_pos;
|
||||
attribute vec2 a_uv;
|
||||
uniform mat4 u_mvp;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
precision mediump float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_use_tex;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
|
||||
vec4 texColor = texture2D(u_tex, v_uv);
|
||||
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||
}
|
||||
"#,
|
||||
),
|
||||
GlBackend::Core33 => (
|
||||
r#"#version 330 core
|
||||
in vec3 a_pos;
|
||||
in vec2 a_uv;
|
||||
uniform mat4 u_mvp;
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||
}
|
||||
"#,
|
||||
r#"#version 330 core
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_use_tex;
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
|
||||
vec4 texColor = texture(u_tex, v_uv);
|
||||
fragColor = mix(base, texColor, u_use_tex);
|
||||
}
|
||||
"#,
|
||||
),
|
||||
};
|
||||
|
||||
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||
let vs = gl
|
||||
.create_shader(glow::VERTEX_SHADER)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fs = gl
|
||||
.create_shader(glow::FRAGMENT_SHADER)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
gl.shader_source(vs, vs_src);
|
||||
gl.compile_shader(vs);
|
||||
if !gl.get_shader_compile_status(vs) {
|
||||
let log = gl.get_shader_info_log(vs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("vertex shader compile failed: {log}"));
|
||||
}
|
||||
|
||||
gl.shader_source(fs, fs_src);
|
||||
gl.compile_shader(fs);
|
||||
if !gl.get_shader_compile_status(fs) {
|
||||
let log = gl.get_shader_info_log(fs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("fragment shader compile failed: {log}"));
|
||||
}
|
||||
|
||||
gl.attach_shader(program, vs);
|
||||
gl.attach_shader(program, fs);
|
||||
gl.link_program(program);
|
||||
|
||||
gl.detach_shader(program, vs);
|
||||
gl.detach_shader(program, fs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
|
||||
if !gl.get_program_link_status(program) {
|
||||
let log = gl.get_program_info_log(program);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("program link failed: {log}"));
|
||||
}
|
||||
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||
for &value in slice {
|
||||
out.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||
for &value in slice {
|
||||
out.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mat4_identity() -> [f32; 16] {
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
0.0, 0.0, 1.0, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0, //
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||
let mut m = mat4_identity();
|
||||
m[12] = x;
|
||||
m[13] = y;
|
||||
m[14] = z;
|
||||
m
|
||||
}
|
||||
|
||||
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
|
||||
let c = rad.cos();
|
||||
let s = rad.sin();
|
||||
[
|
||||
c, 0.0, -s, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
s, 0.0, c, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0, //
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
|
||||
let f = 1.0 / (0.5 * fovy).tan();
|
||||
let nf = 1.0 / (near - far);
|
||||
[
|
||||
f / aspect,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
f,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
(far + near) * nf,
|
||||
-1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
(2.0 * far * near) * nf,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||
let mut out = [0.0f32; 16];
|
||||
for c in 0..4 {
|
||||
for r in 0..4 {
|
||||
let mut acc = 0.0f32;
|
||||
for k in 0..4 {
|
||||
acc += a[k * 4 + r] * b[c * 4 + k];
|
||||
}
|
||||
out[c * 4 + r] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "render-mission-demo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
demo = ["dep:sdl2", "dep:glow"]
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
glow = { version = "0.16", optional = true }
|
||||
nres = { path = "../nres" }
|
||||
render-core = { path = "../render-core" }
|
||||
render-demo = { path = "../render-demo" }
|
||||
tma = { path = "../tma" }
|
||||
terrain-core = { path = "../terrain-core" }
|
||||
texm = { path = "../texm" }
|
||||
unitdat = { path = "../unitdat" }
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||
|
||||
[[bin]]
|
||||
name = "parkan-render-mission-demo"
|
||||
path = "src/main.rs"
|
||||
required-features = ["demo"]
|
||||
@@ -1,881 +0,0 @@
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use nres::Archive;
|
||||
use render_core::{build_render_mesh, RenderMesh};
|
||||
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use terrain_core::TerrainMesh;
|
||||
use tma::MissionFile;
|
||||
|
||||
const MAT0_KIND: u32 = 0x3054_414D;
|
||||
const MESH_KIND: u32 = 0x4853_454D;
|
||||
const OBJECT_REF_STRIDE: usize = 64;
|
||||
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
Mission(tma::Error),
|
||||
Terrain(terrain_core::Error),
|
||||
UnitDat(unitdat::Error),
|
||||
RenderDemo(render_demo::Error),
|
||||
Nres(nres::error::Error),
|
||||
Texm(texm::error::Error),
|
||||
InvalidMapPath(String),
|
||||
GameRootNotFound(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
Self::Mission(err) => write!(f, "{err}"),
|
||||
Self::Terrain(err) => write!(f, "{err}"),
|
||||
Self::UnitDat(err) => write!(f, "{err}"),
|
||||
Self::RenderDemo(err) => write!(f, "{err}"),
|
||||
Self::Nres(err) => write!(f, "{err}"),
|
||||
Self::Texm(err) => write!(f, "{err}"),
|
||||
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
|
||||
Self::GameRootNotFound(path) => {
|
||||
write!(
|
||||
f,
|
||||
"failed to detect game root from mission path {}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
Self::Mission(err) => Some(err),
|
||||
Self::Terrain(err) => Some(err),
|
||||
Self::UnitDat(err) => Some(err),
|
||||
Self::RenderDemo(err) => Some(err),
|
||||
Self::Nres(err) => Some(err),
|
||||
Self::Texm(err) => Some(err),
|
||||
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tma::Error> for Error {
|
||||
fn from(value: tma::Error) -> Self {
|
||||
Self::Mission(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<terrain_core::Error> for Error {
|
||||
fn from(value: terrain_core::Error) -> Self {
|
||||
Self::Terrain(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<unitdat::Error> for Error {
|
||||
fn from(value: unitdat::Error) -> Self {
|
||||
Self::UnitDat(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<render_demo::Error> for Error {
|
||||
fn from(value: render_demo::Error) -> Self {
|
||||
Self::RenderDemo(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nres::error::Error> for Error {
|
||||
fn from(value: nres::error::Error) -> Self {
|
||||
Self::Nres(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<texm::error::Error> for Error {
|
||||
fn from(value: texm::error::Error) -> Self {
|
||||
Self::Texm(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct LoadOptions {
|
||||
pub load_model_textures: bool,
|
||||
pub load_terrain_texture: bool,
|
||||
}
|
||||
|
||||
impl Default for LoadOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
load_model_textures: true,
|
||||
load_terrain_texture: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MissionScene {
|
||||
pub game_root: PathBuf,
|
||||
pub mission_path: PathBuf,
|
||||
pub mission: MissionFile,
|
||||
pub map_folder_rel: PathBuf,
|
||||
pub land_msh_path: PathBuf,
|
||||
pub terrain: TerrainMesh,
|
||||
pub terrain_texture: Option<LoadedTexture>,
|
||||
pub models: Vec<SceneModel>,
|
||||
pub skipped_objects: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SceneModel {
|
||||
pub archive_path: PathBuf,
|
||||
pub model_name: String,
|
||||
pub mesh: RenderMesh,
|
||||
pub texture: Option<LoadedTexture>,
|
||||
pub instances: Vec<ModelInstance>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct ModelInstance {
|
||||
pub position: [f32; 3],
|
||||
pub yaw_rad: f32,
|
||||
pub scale: [f32; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ObjectPrototype {
|
||||
archive_path: PathBuf,
|
||||
model_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ObjectRef {
|
||||
archive_name: String,
|
||||
resource_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
struct ModelKey {
|
||||
archive_path: PathBuf,
|
||||
model_name: String,
|
||||
}
|
||||
|
||||
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
|
||||
let mut cursor = mission_path.parent();
|
||||
while let Some(dir) = cursor {
|
||||
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
cursor = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn load_scene(
|
||||
game_root: impl AsRef<Path>,
|
||||
mission_path: impl AsRef<Path>,
|
||||
) -> Result<MissionScene> {
|
||||
load_scene_with_options(game_root, mission_path, LoadOptions::default())
|
||||
}
|
||||
|
||||
pub fn load_scene_with_options(
|
||||
game_root: impl AsRef<Path>,
|
||||
mission_path: impl AsRef<Path>,
|
||||
options: LoadOptions,
|
||||
) -> Result<MissionScene> {
|
||||
let game_root = game_root.as_ref().to_path_buf();
|
||||
let mission_path = mission_path.as_ref().to_path_buf();
|
||||
|
||||
let mission = tma::parse_path(&mission_path)?;
|
||||
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
|
||||
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
|
||||
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
|
||||
let terrain_texture = if options.load_terrain_texture {
|
||||
resolve_terrain_texture(&game_root, &map_folder_rel)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
|
||||
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
|
||||
let mut skipped = 0usize;
|
||||
|
||||
for object in &mission.objects {
|
||||
let cache_key = object.resource_name.to_ascii_lowercase();
|
||||
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
|
||||
cached.clone()
|
||||
} else {
|
||||
let resolved = resolve_object_prototype(&game_root, object)?;
|
||||
prototype_cache.insert(cache_key, resolved.clone());
|
||||
resolved
|
||||
};
|
||||
|
||||
let Some(proto) = proto else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let instance = ModelInstance {
|
||||
position: object.position,
|
||||
yaw_rad: object.orientation[2],
|
||||
scale: normalize_scale(object.scale),
|
||||
};
|
||||
|
||||
grouped_instances
|
||||
.entry(ModelKey {
|
||||
archive_path: proto.archive_path,
|
||||
model_name: proto.model_name,
|
||||
})
|
||||
.or_default()
|
||||
.push(instance);
|
||||
}
|
||||
|
||||
let mut models = Vec::new();
|
||||
for (key, instances) in grouped_instances {
|
||||
let loaded =
|
||||
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
skipped += instances.len();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mesh = build_render_mesh(&loaded.model, 0, 0);
|
||||
if mesh.indices.is_empty() {
|
||||
skipped += instances.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
let texture = if options.load_model_textures {
|
||||
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
models.push(SceneModel {
|
||||
archive_path: key.archive_path,
|
||||
model_name: loaded.name,
|
||||
mesh,
|
||||
texture,
|
||||
instances,
|
||||
});
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
|
||||
|
||||
Ok(MissionScene {
|
||||
game_root,
|
||||
mission_path,
|
||||
mission,
|
||||
map_folder_rel,
|
||||
land_msh_path,
|
||||
terrain,
|
||||
terrain_texture,
|
||||
models,
|
||||
skipped_objects: skipped,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
|
||||
let mut min_v = [f32::INFINITY; 3];
|
||||
let mut max_v = [f32::NEG_INFINITY; 3];
|
||||
let mut any = false;
|
||||
|
||||
for pos in &scene.terrain.positions {
|
||||
merge_bounds(&mut min_v, &mut max_v, *pos);
|
||||
any = true;
|
||||
}
|
||||
|
||||
for model in &scene.models {
|
||||
for instance in &model.instances {
|
||||
merge_bounds(&mut min_v, &mut max_v, instance.position);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
|
||||
any.then_some((min_v, max_v))
|
||||
}
|
||||
|
||||
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
|
||||
for i in 0..3 {
|
||||
if p[i] < min_v[i] {
|
||||
min_v[i] = p[i];
|
||||
}
|
||||
if p[i] > max_v[i] {
|
||||
max_v[i] = p[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
|
||||
let mut out = scale;
|
||||
for item in &mut out {
|
||||
if !item.is_finite() || item.abs() < 0.000_1 {
|
||||
*item = 1.0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
|
||||
let mut parts = split_relative_path(map_path);
|
||||
if parts.len() < 2 {
|
||||
return Err(Error::InvalidMapPath(map_path.to_string()));
|
||||
}
|
||||
parts.pop(); // remove 'land'
|
||||
|
||||
let mut out = PathBuf::new();
|
||||
for part in parts {
|
||||
out.push(part);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn resolve_object_prototype(
|
||||
game_root: &Path,
|
||||
object: &tma::MissionObject,
|
||||
) -> Result<Option<ObjectPrototype>> {
|
||||
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
|
||||
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
|
||||
if !dat_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parsed = unitdat::parse_path(&dat_path)?;
|
||||
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
|
||||
if !archive_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
|
||||
}
|
||||
|
||||
let archive_path = game_root.join("objects.rlb");
|
||||
if !archive_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
resolve_archive_model(game_root, &archive_path, &object.resource_name)
|
||||
}
|
||||
|
||||
fn resolve_archive_model(
|
||||
game_root: &Path,
|
||||
archive_path: &Path,
|
||||
model_key: &str,
|
||||
) -> Result<Option<ObjectPrototype>> {
|
||||
if !archive_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if is_objects_registry_archive(archive_path) {
|
||||
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
|
||||
return Ok(Some(proto));
|
||||
}
|
||||
}
|
||||
|
||||
let model_name = ensure_msh_suffix(model_key);
|
||||
if !archive_has_mesh_entry(archive_path, &model_name)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(ObjectPrototype {
|
||||
archive_path: archive_path.to_path_buf(),
|
||||
model_name: model_name.to_ascii_lowercase(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_objects_registry_archive(archive_path: &Path) -> bool {
|
||||
archive_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
|
||||
}
|
||||
|
||||
fn resolve_objects_registry_model(
|
||||
game_root: &Path,
|
||||
registry_archive_path: &Path,
|
||||
object_key: &str,
|
||||
) -> Result<Option<ObjectPrototype>> {
|
||||
let archive = Archive::open_path(registry_archive_path)?;
|
||||
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let payload = archive.read(entry_id)?.into_owned();
|
||||
let refs = parse_object_refs(&payload);
|
||||
if refs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for item in refs
|
||||
.iter()
|
||||
.filter(|item| has_extension(&item.resource_name, "msh"))
|
||||
{
|
||||
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
|
||||
return Ok(Some(proto));
|
||||
}
|
||||
}
|
||||
|
||||
for item in refs
|
||||
.iter()
|
||||
.filter(|item| has_extension(&item.resource_name, "bas"))
|
||||
{
|
||||
let Some(stem) = Path::new(&item.resource_name)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if stem.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = format!("{stem}.msh");
|
||||
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
|
||||
return Ok(Some(proto));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
|
||||
mesh_name_candidates(object_key)
|
||||
.into_iter()
|
||||
.find_map(|candidate| archive.find(&candidate))
|
||||
}
|
||||
|
||||
fn resolve_object_ref_model(
|
||||
game_root: &Path,
|
||||
item: &ObjectRef,
|
||||
model_name: &str,
|
||||
) -> Result<Option<ObjectPrototype>> {
|
||||
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
|
||||
if !archive_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !archive_has_mesh_entry(&archive_path, model_name)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(ObjectPrototype {
|
||||
archive_path,
|
||||
model_name: model_name.to_ascii_lowercase(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
|
||||
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
|
||||
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
|
||||
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
|
||||
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
|
||||
if archive_name.is_empty() || resource_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
refs.push(ObjectRef {
|
||||
archive_name,
|
||||
resource_name,
|
||||
});
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
|
||||
let archive = Archive::open_path(archive_path)?;
|
||||
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
|
||||
}
|
||||
|
||||
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
|
||||
for candidate in mesh_name_candidates(requested_name) {
|
||||
let Some(id) = archive.find(&candidate) else {
|
||||
continue;
|
||||
};
|
||||
let Some(entry) = archive.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mesh_name_candidates(name: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
push_unique_string(&mut out, trimmed.to_string());
|
||||
if let Some(stem) = trimmed
|
||||
.strip_suffix(".msh")
|
||||
.or_else(|| trimmed.strip_suffix(".MSH"))
|
||||
{
|
||||
if !stem.is_empty() {
|
||||
push_unique_string(&mut out, stem.to_string());
|
||||
}
|
||||
} else {
|
||||
push_unique_string(&mut out, format!("{trimmed}.msh"));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn push_unique_string(items: &mut Vec<String>, value: String) {
|
||||
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
|
||||
items.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_msh_suffix(name: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.to_ascii_lowercase().ends_with(".msh") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}.msh")
|
||||
}
|
||||
}
|
||||
|
||||
fn has_extension(name: &str, ext: &str) -> bool {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
|
||||
}
|
||||
|
||||
fn resolve_terrain_texture(
|
||||
game_root: &Path,
|
||||
map_folder_rel: &Path,
|
||||
) -> Result<Option<LoadedTexture>> {
|
||||
let material_archive_path = game_root.join("material.lib");
|
||||
let texture_archive_path = game_root.join("textures.lib");
|
||||
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for wear_name in ["Land1.wea", "Land2.wea"] {
|
||||
let wear_path = game_root.join(map_folder_rel).join(wear_name);
|
||||
if !wear_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let wear_payload = fs::read(&wear_path)?;
|
||||
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
|
||||
continue;
|
||||
};
|
||||
let Some(texture_name) =
|
||||
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
|
||||
return Ok(Some(texture));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
|
||||
let text = decode_cp1251(bytes).replace('\r', "");
|
||||
let mut lines = text.lines();
|
||||
let count = lines.next()?.trim().parse::<usize>().ok()?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
for line in lines.take(count) {
|
||||
let mut parts = line.split_whitespace();
|
||||
let _legacy = parts.next()?;
|
||||
let name = parts.next()?;
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_texture_name_from_material_archive(
|
||||
archive_path: &Path,
|
||||
material_name: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let archive = Archive::open_path(archive_path)?;
|
||||
|
||||
let entry = if let Some(id) = archive.find(material_name) {
|
||||
archive
|
||||
.get(id)
|
||||
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||
.or_else(|| {
|
||||
archive
|
||||
.find("DEFAULT")
|
||||
.and_then(|id| archive.get(id))
|
||||
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||
})
|
||||
} else {
|
||||
archive
|
||||
.find("DEFAULT")
|
||||
.and_then(|id| archive.get(id))
|
||||
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||
}
|
||||
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
|
||||
|
||||
let Some(entry) = entry else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let payload = archive.read(entry.id)?.into_owned();
|
||||
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
|
||||
}
|
||||
|
||||
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
|
||||
if payload.len() < 4 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
if phase_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = 4usize;
|
||||
if attr2 >= 2 {
|
||||
offset = offset.saturating_add(2);
|
||||
}
|
||||
if attr2 >= 3 {
|
||||
offset = offset.saturating_add(4);
|
||||
}
|
||||
if attr2 >= 4 {
|
||||
offset = offset.saturating_add(4);
|
||||
}
|
||||
|
||||
for phase in 0..phase_count {
|
||||
let phase_off = offset.saturating_add(phase.saturating_mul(34));
|
||||
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
|
||||
break;
|
||||
};
|
||||
let name_raw = &rec[18..34];
|
||||
let end = name_raw
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(name_raw.len());
|
||||
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
|
||||
if !name.is_empty() {
|
||||
return Ok(Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
|
||||
let archive = Archive::open_path(archive_path)?;
|
||||
let Some(id) = archive.find(texture_name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(entry) = archive.get(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.meta.kind != texm::TEXM_MAGIC {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload = archive.read(id)?.into_owned();
|
||||
let parsed = texm::parse_texm(&payload)?;
|
||||
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
|
||||
|
||||
Ok(Some(LoadedTexture {
|
||||
name: entry.meta.name.clone(),
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
rgba8: decoded.rgba8,
|
||||
}))
|
||||
}
|
||||
|
||||
fn split_relative_path(path: &str) -> Vec<&str> {
|
||||
path.split(['\\', '/'])
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pathbuf_from_rel(path: &str) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for part in split_relative_path(path) {
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn game_root() -> Option<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("Parkan - Iron Strategy");
|
||||
root.is_dir().then_some(root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_game_root_from_mission_path() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let mission = root
|
||||
.join("MISSIONS")
|
||||
.join("CAMPAIGN")
|
||||
.join("CAMPAIGN.00")
|
||||
.join("Mission.01")
|
||||
.join("data.tma");
|
||||
if !mission.is_file() {
|
||||
eprintln!("skipping missing mission sample");
|
||||
return;
|
||||
}
|
||||
|
||||
let detected = detect_game_root_from_mission_path(&mission)
|
||||
.expect("failed to detect game root from mission path");
|
||||
assert_eq!(detected, root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_scene_cpu_without_textures() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let mission = root
|
||||
.join("MISSIONS")
|
||||
.join("CAMPAIGN")
|
||||
.join("CAMPAIGN.00")
|
||||
.join("Mission.01")
|
||||
.join("data.tma");
|
||||
if !mission.is_file() {
|
||||
eprintln!("skipping missing mission sample");
|
||||
return;
|
||||
}
|
||||
|
||||
let scene = load_scene_with_options(
|
||||
&root,
|
||||
&mission,
|
||||
LoadOptions {
|
||||
load_model_textures: false,
|
||||
load_terrain_texture: false,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
|
||||
|
||||
assert!(!scene.terrain.positions.is_empty());
|
||||
assert!(!scene.terrain.faces.is_empty());
|
||||
assert!(!scene.models.is_empty());
|
||||
|
||||
let instance_count = scene
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.instances.len())
|
||||
.sum::<usize>();
|
||||
assert!(instance_count >= 10);
|
||||
|
||||
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
|
||||
assert!(bounds.0[0] <= bounds.1[0]);
|
||||
assert!(bounds.0[1] <= bounds.1[1]);
|
||||
assert!(bounds.0[2] <= bounds.1[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_scene_with_textures() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let mission = root
|
||||
.join("MISSIONS")
|
||||
.join("CAMPAIGN")
|
||||
.join("CAMPAIGN.00")
|
||||
.join("Mission.01")
|
||||
.join("data.tma");
|
||||
if !mission.is_file() {
|
||||
eprintln!("skipping missing mission sample");
|
||||
return;
|
||||
}
|
||||
|
||||
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
|
||||
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
|
||||
|
||||
assert!(!scene.models.is_empty());
|
||||
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
|
||||
assert!(textured_models > 0, "no model textures resolved");
|
||||
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_objects_registry_models() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let registry = root.join("objects.rlb");
|
||||
if !registry.is_file() {
|
||||
eprintln!("skipping missing objects.rlb");
|
||||
return;
|
||||
}
|
||||
|
||||
let cases = [
|
||||
("r_h_01", "bases.rlb", "r_h_01.msh"),
|
||||
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
|
||||
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
|
||||
];
|
||||
|
||||
for (key, archive_name, model_name) in cases {
|
||||
let proto = resolve_objects_registry_model(&root, ®istry, key)
|
||||
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
|
||||
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
|
||||
|
||||
let got_archive = proto
|
||||
.archive_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
|
||||
assert!(
|
||||
proto.model_name.eq_ignore_ascii_case(model_name),
|
||||
"unexpected model for key '{key}': got '{}', expected '{}'",
|
||||
proto.model_name,
|
||||
model_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,924 +0,0 @@
|
||||
use glow::HasContext as _;
|
||||
use render_mission_demo::{
|
||||
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
|
||||
MissionScene, ModelInstance,
|
||||
};
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Args {
|
||||
mission: PathBuf,
|
||||
game_root: Option<PathBuf>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fov_deg: f32,
|
||||
no_model_texture: bool,
|
||||
no_terrain_texture: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum GlBackend {
|
||||
Gles2,
|
||||
Core33,
|
||||
}
|
||||
|
||||
struct GpuTexture {
|
||||
handle: glow::NativeTexture,
|
||||
}
|
||||
|
||||
struct GpuRenderable {
|
||||
vbo: glow::NativeBuffer,
|
||||
ebo: glow::NativeBuffer,
|
||||
index_count: usize,
|
||||
texture: Option<GpuTexture>,
|
||||
}
|
||||
|
||||
struct ModelRenderable {
|
||||
gpu: GpuRenderable,
|
||||
instances: Vec<ModelInstance>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
struct Camera {
|
||||
position: [f32; 3],
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
move_speed: f32,
|
||||
mouse_sensitivity: f32,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut mission = None;
|
||||
let mut game_root = None;
|
||||
let mut width = 1600u32;
|
||||
let mut height = 900u32;
|
||||
let mut fov_deg = 60.0f32;
|
||||
let mut no_model_texture = false;
|
||||
let mut no_terrain_texture = false;
|
||||
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--mission" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --mission"))?;
|
||||
mission = Some(PathBuf::from(value));
|
||||
}
|
||||
"--game-root" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --game-root"))?;
|
||||
game_root = Some(PathBuf::from(value));
|
||||
}
|
||||
"--width" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --width"))?;
|
||||
width = value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| String::from("invalid --width value"))?;
|
||||
if width == 0 {
|
||||
return Err(String::from("--width must be > 0"));
|
||||
}
|
||||
}
|
||||
"--height" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --height"))?;
|
||||
height = value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| String::from("invalid --height value"))?;
|
||||
if height == 0 {
|
||||
return Err(String::from("--height must be > 0"));
|
||||
}
|
||||
}
|
||||
"--fov" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||
fov_deg = value
|
||||
.parse::<f32>()
|
||||
.map_err(|_| String::from("invalid --fov value"))?;
|
||||
if !(1.0..=179.0).contains(&fov_deg) {
|
||||
return Err(String::from("--fov must be in range [1, 179]"));
|
||||
}
|
||||
}
|
||||
"--no-model-texture" => {
|
||||
no_model_texture = true;
|
||||
}
|
||||
"--no-terrain-texture" => {
|
||||
no_terrain_texture = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
|
||||
Ok(Args {
|
||||
mission,
|
||||
game_root,
|
||||
width,
|
||||
height,
|
||||
fov_deg,
|
||||
no_model_texture,
|
||||
no_terrain_texture,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
|
||||
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
|
||||
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = match parse_args() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = run(args) {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: Args) -> Result<(), String> {
|
||||
let game_root = if let Some(path) = args.game_root.clone() {
|
||||
path
|
||||
} else {
|
||||
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
|
||||
format!(
|
||||
"failed to detect game root from mission path {} (use --game-root)",
|
||||
args.mission.display()
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let scene = load_scene_with_options(
|
||||
&game_root,
|
||||
&args.mission,
|
||||
LoadOptions {
|
||||
load_model_textures: !args.no_model_texture,
|
||||
load_terrain_texture: !args.no_terrain_texture,
|
||||
},
|
||||
)
|
||||
.map_err(|err| format!("failed to load mission scene: {err}"))?;
|
||||
|
||||
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
|
||||
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
|
||||
|
||||
let instance_count = scene
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.instances.len())
|
||||
.sum::<usize>();
|
||||
println!(
|
||||
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
|
||||
scene.mission.footer.map_path,
|
||||
scene.terrain.positions.len(),
|
||||
scene.terrain.faces.len(),
|
||||
scene.models.len(),
|
||||
instance_count,
|
||||
scene.skipped_objects
|
||||
);
|
||||
|
||||
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||
let video = sdl
|
||||
.video()
|
||||
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||
|
||||
let (mut window, _gl_ctx, gl_backend) =
|
||||
create_window_and_context(&video, args.width, args.height)?;
|
||||
let _ = video.gl_set_swap_interval(1);
|
||||
|
||||
let gl = unsafe {
|
||||
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||
};
|
||||
|
||||
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
||||
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
|
||||
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||
|
||||
let terrain_gpu =
|
||||
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
|
||||
|
||||
let mut model_gpus = Vec::new();
|
||||
for model in &scene.models {
|
||||
let renderable = unsafe { upload_model_renderable(&gl, model)? };
|
||||
model_gpus.push(renderable);
|
||||
}
|
||||
|
||||
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
|
||||
let mut camera = Camera {
|
||||
position: [
|
||||
scene_center[0],
|
||||
scene_center[1] + scene_radius * 0.6,
|
||||
scene_center[2] + scene_radius * 1.4,
|
||||
],
|
||||
yaw: std::f32::consts::PI,
|
||||
pitch: -0.28,
|
||||
move_speed: (scene_radius * 0.55).max(60.0),
|
||||
mouse_sensitivity: 0.005,
|
||||
};
|
||||
|
||||
let mut events = sdl
|
||||
.event_pump()
|
||||
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||
let mut last = Instant::now();
|
||||
let mut fps_window_start = Instant::now();
|
||||
let mut fps_frames = 0u32;
|
||||
let mut fps_printed = false;
|
||||
let mut mouse_look = false;
|
||||
|
||||
'main_loop: loop {
|
||||
for event in events.poll_iter() {
|
||||
match event {
|
||||
sdl2::event::Event::Quit { .. } => break 'main_loop,
|
||||
sdl2::event::Event::KeyDown {
|
||||
keycode: Some(sdl2::keyboard::Keycode::Escape),
|
||||
..
|
||||
} => break 'main_loop,
|
||||
sdl2::event::Event::MouseButtonDown {
|
||||
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||
..
|
||||
} => {
|
||||
mouse_look = true;
|
||||
sdl.mouse().set_relative_mouse_mode(true);
|
||||
}
|
||||
sdl2::event::Event::MouseButtonUp {
|
||||
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||
..
|
||||
} => {
|
||||
mouse_look = false;
|
||||
sdl.mouse().set_relative_mouse_mode(false);
|
||||
}
|
||||
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
|
||||
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
|
||||
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
|
||||
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
|
||||
last = now;
|
||||
|
||||
update_camera(&events, &mut camera, dt);
|
||||
|
||||
let (w, h) = window.size();
|
||||
let proj = mat4_perspective(
|
||||
args.fov_deg.to_radians(),
|
||||
(w as f32 / h.max(1) as f32).max(0.01),
|
||||
0.1,
|
||||
(scene_radius * 25.0).max(5000.0),
|
||||
);
|
||||
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||
let view = mat4_look_at(
|
||||
camera.position,
|
||||
[
|
||||
camera.position[0] + forward[0],
|
||||
camera.position[1] + forward[1],
|
||||
camera.position[2] + forward[2],
|
||||
],
|
||||
[0.0, 1.0, 0.0],
|
||||
);
|
||||
|
||||
unsafe {
|
||||
draw_frame_begin(&gl, w, h);
|
||||
|
||||
let terrain_mvp = mat4_mul(&proj, &view);
|
||||
draw_gpu_renderable(
|
||||
&gl,
|
||||
program,
|
||||
u_mvp.as_ref(),
|
||||
u_use_tex.as_ref(),
|
||||
u_tex.as_ref(),
|
||||
a_pos,
|
||||
a_uv,
|
||||
&terrain_gpu,
|
||||
&terrain_mvp,
|
||||
);
|
||||
|
||||
for model in &model_gpus {
|
||||
for instance in &model.instances {
|
||||
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
|
||||
let view_model = mat4_mul(&view, &model_m);
|
||||
let mvp = mat4_mul(&proj, &view_model);
|
||||
draw_gpu_renderable(
|
||||
&gl,
|
||||
program,
|
||||
u_mvp.as_ref(),
|
||||
u_use_tex.as_ref(),
|
||||
u_tex.as_ref(),
|
||||
a_pos,
|
||||
a_uv,
|
||||
&model.gpu,
|
||||
&mvp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.gl_swap_window();
|
||||
|
||||
fps_frames = fps_frames.saturating_add(1);
|
||||
let elapsed = fps_window_start.elapsed();
|
||||
if elapsed >= Duration::from_millis(500) {
|
||||
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||
let _ = window.set_title(&format!(
|
||||
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
|
||||
));
|
||||
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||
let _ = std::io::stdout().flush();
|
||||
fps_printed = true;
|
||||
fps_frames = 0;
|
||||
fps_window_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
if fps_printed {
|
||||
println!();
|
||||
}
|
||||
|
||||
unsafe {
|
||||
cleanup_renderable(&gl, terrain_gpu);
|
||||
for model in model_gpus {
|
||||
cleanup_renderable(&gl, model.gpu);
|
||||
}
|
||||
gl.delete_program(program);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
|
||||
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
|
||||
let center = [
|
||||
0.5 * (min_v[0] + max_v[0]),
|
||||
0.5 * (min_v[1] + max_v[1]),
|
||||
0.5 * (min_v[2] + max_v[2]),
|
||||
];
|
||||
let extent = [
|
||||
max_v[0] - min_v[0],
|
||||
max_v[1] - min_v[1],
|
||||
max_v[2] - min_v[2],
|
||||
];
|
||||
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
|
||||
.sqrt()
|
||||
.max(10.0)
|
||||
* 0.5;
|
||||
return (center, radius);
|
||||
}
|
||||
([0.0, 0.0, 0.0], 100.0)
|
||||
}
|
||||
|
||||
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
|
||||
use sdl2::keyboard::Scancode;
|
||||
|
||||
let keys = events.keyboard_state();
|
||||
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
|
||||
|
||||
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
|
||||
|
||||
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
|
||||
move_dir[0] += forward[0];
|
||||
move_dir[1] += forward[1];
|
||||
move_dir[2] += forward[2];
|
||||
}
|
||||
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
|
||||
move_dir[0] -= forward[0];
|
||||
move_dir[1] -= forward[1];
|
||||
move_dir[2] -= forward[2];
|
||||
}
|
||||
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
|
||||
move_dir[0] -= right[0];
|
||||
move_dir[1] -= right[1];
|
||||
move_dir[2] -= right[2];
|
||||
}
|
||||
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
|
||||
move_dir[0] += right[0];
|
||||
move_dir[1] += right[1];
|
||||
move_dir[2] += right[2];
|
||||
}
|
||||
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
|
||||
move_dir[1] += 1.0;
|
||||
}
|
||||
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
|
||||
move_dir[1] -= 1.0;
|
||||
}
|
||||
|
||||
let shift =
|
||||
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
|
||||
let speed_mul = if shift { 3.0 } else { 1.0 };
|
||||
|
||||
let norm = normalize3(move_dir);
|
||||
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
|
||||
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
|
||||
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
|
||||
}
|
||||
|
||||
unsafe fn upload_model_renderable(
|
||||
gl: &glow::Context,
|
||||
model: &render_mission_demo::SceneModel,
|
||||
) -> Result<ModelRenderable, String> {
|
||||
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
|
||||
for vertex in &model.mesh.vertices {
|
||||
vertex_data.push(vertex.position[0]);
|
||||
vertex_data.push(vertex.position[1]);
|
||||
vertex_data.push(vertex.position[2]);
|
||||
vertex_data.push(vertex.uv0[0]);
|
||||
vertex_data.push(vertex.uv0[1]);
|
||||
}
|
||||
|
||||
let gpu = upload_gpu_renderable(
|
||||
gl,
|
||||
&vertex_data,
|
||||
&model.mesh.indices,
|
||||
model.texture.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(ModelRenderable {
|
||||
gpu,
|
||||
instances: model.instances.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn upload_terrain_renderable(
|
||||
gl: &glow::Context,
|
||||
mesh: &terrain_core::TerrainRenderMesh,
|
||||
texture: Option<&render_demo::LoadedTexture>,
|
||||
) -> Result<GpuRenderable, String> {
|
||||
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||
for vertex in &mesh.vertices {
|
||||
vertex_data.push(vertex.position[0]);
|
||||
vertex_data.push(vertex.position[1]);
|
||||
vertex_data.push(vertex.position[2]);
|
||||
vertex_data.push(vertex.uv0[0]);
|
||||
vertex_data.push(vertex.uv0[1]);
|
||||
}
|
||||
|
||||
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
|
||||
}
|
||||
|
||||
unsafe fn upload_gpu_renderable(
|
||||
gl: &glow::Context,
|
||||
vertices: &[f32],
|
||||
indices: &[u16],
|
||||
texture: Option<&render_demo::LoadedTexture>,
|
||||
) -> Result<GpuRenderable, String> {
|
||||
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||
|
||||
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
|
||||
let index_bytes = u16_slice_to_ne_bytes(indices);
|
||||
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||
|
||||
let gpu_texture = if let Some(texture) = texture {
|
||||
Some(create_texture(gl, texture)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(GpuRenderable {
|
||||
vbo,
|
||||
ebo,
|
||||
index_count: indices.len(),
|
||||
texture: gpu_texture,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
|
||||
if let Some(tex) = renderable.texture {
|
||||
gl.delete_texture(tex.handle);
|
||||
}
|
||||
gl.delete_buffer(renderable.ebo);
|
||||
gl.delete_buffer(renderable.vbo);
|
||||
}
|
||||
|
||||
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
|
||||
gl.viewport(
|
||||
0,
|
||||
0,
|
||||
width.min(i32::MAX as u32) as i32,
|
||||
height.min(i32::MAX as u32) as i32,
|
||||
);
|
||||
gl.enable(glow::DEPTH_TEST);
|
||||
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||
}
|
||||
|
||||
unsafe fn draw_gpu_renderable(
|
||||
gl: &glow::Context,
|
||||
program: glow::NativeProgram,
|
||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||
u_tex: Option<&glow::NativeUniformLocation>,
|
||||
a_pos: u32,
|
||||
a_uv: u32,
|
||||
renderable: &GpuRenderable,
|
||||
mvp: &[f32; 16],
|
||||
) {
|
||||
gl.use_program(Some(program));
|
||||
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||
|
||||
let texture_enabled = renderable.texture.is_some();
|
||||
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
|
||||
|
||||
if let Some(tex) = &renderable.texture {
|
||||
gl.active_texture(glow::TEXTURE0);
|
||||
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
|
||||
gl.uniform_1_i32(u_tex, 0);
|
||||
} else {
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
}
|
||||
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
|
||||
gl.enable_vertex_attrib_array(a_pos);
|
||||
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||
gl.enable_vertex_attrib_array(a_uv);
|
||||
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||
|
||||
gl.draw_elements(
|
||||
glow::TRIANGLES,
|
||||
renderable.index_count.min(i32::MAX as usize) as i32,
|
||||
glow::UNSIGNED_SHORT,
|
||||
0,
|
||||
);
|
||||
|
||||
gl.disable_vertex_attrib_array(a_uv);
|
||||
gl.disable_vertex_attrib_array(a_pos);
|
||||
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
gl.use_program(None);
|
||||
}
|
||||
|
||||
fn create_window_and_context(
|
||||
video: &sdl2::VideoSubsystem,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||
let candidates = [
|
||||
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||
];
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (backend, profile, major, minor) in candidates {
|
||||
{
|
||||
let gl_attr = video.gl_attr();
|
||||
gl_attr.set_context_profile(profile);
|
||||
gl_attr.set_context_version(major, minor);
|
||||
gl_attr.set_depth_size(24);
|
||||
gl_attr.set_double_buffer(true);
|
||||
}
|
||||
|
||||
let mut window_builder = video.window("Parkan Mission Demo", width, height);
|
||||
window_builder.opengl().resizable();
|
||||
|
||||
let window = match window_builder.build() {
|
||||
Ok(window) => window,
|
||||
Err(err) => {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let gl_ctx = match window.gl_create_context() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||
errors.push(format!(
|
||||
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok((window, gl_ctx, backend));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"failed to create OpenGL context. Attempts: {}",
|
||||
errors.join(" | ")
|
||||
))
|
||||
}
|
||||
|
||||
unsafe fn create_texture(
|
||||
gl: &glow::Context,
|
||||
texture: &render_demo::LoadedTexture,
|
||||
) -> Result<GpuTexture, String> {
|
||||
let handle = gl.create_texture().map_err(|e| e.to_string())?;
|
||||
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MIN_FILTER,
|
||||
glow::LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(
|
||||
glow::TEXTURE_2D,
|
||||
glow::TEXTURE_MAG_FILTER,
|
||||
glow::LINEAR as i32,
|
||||
);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
|
||||
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
|
||||
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
|
||||
gl.tex_image_2d(
|
||||
glow::TEXTURE_2D,
|
||||
0,
|
||||
glow::RGBA as i32,
|
||||
texture.width.min(i32::MAX as u32) as i32,
|
||||
texture.height.min(i32::MAX as u32) as i32,
|
||||
0,
|
||||
glow::RGBA,
|
||||
glow::UNSIGNED_BYTE,
|
||||
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
|
||||
);
|
||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||
Ok(GpuTexture { handle })
|
||||
}
|
||||
|
||||
unsafe fn create_program(
|
||||
gl: &glow::Context,
|
||||
backend: GlBackend,
|
||||
) -> Result<glow::NativeProgram, String> {
|
||||
let (vs_src, fs_src) = match backend {
|
||||
GlBackend::Gles2 => (
|
||||
r#"
|
||||
attribute vec3 a_pos;
|
||||
attribute vec2 a_uv;
|
||||
uniform mat4 u_mvp;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||
}
|
||||
"#,
|
||||
r#"
|
||||
precision mediump float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_use_tex;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||
vec4 texColor = texture2D(u_tex, v_uv);
|
||||
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||
}
|
||||
"#,
|
||||
),
|
||||
GlBackend::Core33 => (
|
||||
r#"#version 330 core
|
||||
in vec3 a_pos;
|
||||
in vec2 a_uv;
|
||||
uniform mat4 u_mvp;
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||
}
|
||||
"#,
|
||||
r#"#version 330 core
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_use_tex;
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||
vec4 texColor = texture(u_tex, v_uv);
|
||||
fragColor = mix(base, texColor, u_use_tex);
|
||||
}
|
||||
"#,
|
||||
),
|
||||
};
|
||||
|
||||
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||
let vs = gl
|
||||
.create_shader(glow::VERTEX_SHADER)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fs = gl
|
||||
.create_shader(glow::FRAGMENT_SHADER)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
gl.shader_source(vs, vs_src);
|
||||
gl.compile_shader(vs);
|
||||
if !gl.get_shader_compile_status(vs) {
|
||||
let log = gl.get_shader_info_log(vs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("vertex shader compile failed: {log}"));
|
||||
}
|
||||
|
||||
gl.shader_source(fs, fs_src);
|
||||
gl.compile_shader(fs);
|
||||
if !gl.get_shader_compile_status(fs) {
|
||||
let log = gl.get_shader_info_log(fs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("fragment shader compile failed: {log}"));
|
||||
}
|
||||
|
||||
gl.attach_shader(program, vs);
|
||||
gl.attach_shader(program, fs);
|
||||
gl.link_program(program);
|
||||
|
||||
gl.detach_shader(program, vs);
|
||||
gl.detach_shader(program, fs);
|
||||
gl.delete_shader(vs);
|
||||
gl.delete_shader(fs);
|
||||
|
||||
if !gl.get_program_link_status(program) {
|
||||
let log = gl.get_program_info_log(program);
|
||||
gl.delete_program(program);
|
||||
return Err(format!("program link failed: {log}"));
|
||||
}
|
||||
|
||||
Ok(program)
|
||||
}
|
||||
|
||||
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
|
||||
let translation = mat4_translation(position[0], position[1], position[2]);
|
||||
let rotation = mat4_rotation_y(yaw);
|
||||
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
|
||||
let tr = mat4_mul(&translation, &rotation);
|
||||
mat4_mul(&tr, &scaling)
|
||||
}
|
||||
|
||||
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
|
||||
let cp = pitch.cos();
|
||||
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
|
||||
}
|
||||
|
||||
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
}
|
||||
|
||||
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
fn normalize3(v: [f32; 3]) -> [f32; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len <= 1e-6 {
|
||||
[0.0, 0.0, 0.0]
|
||||
} else {
|
||||
[v[0] / len, v[1] / len, v[2] / len]
|
||||
}
|
||||
}
|
||||
|
||||
fn mat4_identity() -> [f32; 16] {
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
0.0, 0.0, 1.0, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0, //
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||
let mut m = mat4_identity();
|
||||
m[12] = x;
|
||||
m[13] = y;
|
||||
m[14] = z;
|
||||
m
|
||||
}
|
||||
|
||||
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||
[
|
||||
x, 0.0, 0.0, 0.0, //
|
||||
0.0, y, 0.0, 0.0, //
|
||||
0.0, 0.0, z, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0, //
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
|
||||
let c = rad.cos();
|
||||
let s = rad.sin();
|
||||
[
|
||||
c, 0.0, -s, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
s, 0.0, c, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0, //
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
|
||||
let f = 1.0 / (0.5 * fovy).tan();
|
||||
let nf = 1.0 / (near - far);
|
||||
[
|
||||
f / aspect,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
f,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
(far + near) * nf,
|
||||
-1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
(2.0 * far * near) * nf,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
|
||||
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
|
||||
let s = normalize3(cross3(f, up));
|
||||
let u = cross3(s, f);
|
||||
|
||||
[
|
||||
s[0],
|
||||
u[0],
|
||||
-f[0],
|
||||
0.0,
|
||||
s[1],
|
||||
u[1],
|
||||
-f[1],
|
||||
0.0,
|
||||
s[2],
|
||||
u[2],
|
||||
-f[2],
|
||||
0.0,
|
||||
-dot3(s, eye),
|
||||
-dot3(u, eye),
|
||||
dot3(f, eye),
|
||||
1.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||
let mut out = [0.0f32; 16];
|
||||
for c in 0..4 {
|
||||
for r in 0..4 {
|
||||
let mut acc = 0.0f32;
|
||||
for k in 0..4 {
|
||||
acc += a[k * 4 + r] * b[c * 4 + k];
|
||||
}
|
||||
out[c * 4 + r] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||
for &value in slice {
|
||||
out.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||
for &value in slice {
|
||||
out.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "render-parity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "1.0"
|
||||
@@ -1,16 +0,0 @@
|
||||
# render-parity
|
||||
|
||||
Deterministic frame-diff runner for `parkan-render-demo`.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
cargo run -p render-parity -- \
|
||||
--manifest parity/cases.toml \
|
||||
--output-dir target/render-parity/current
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
|
||||
- `--keep-going`: continue all cases even after failures.
|
||||
@@ -1,212 +0,0 @@
|
||||
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct ManifestMeta {
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub lod: Option<usize>,
|
||||
pub group: Option<usize>,
|
||||
pub angle: Option<f32>,
|
||||
pub diff_threshold: Option<u8>,
|
||||
pub max_mean_abs: Option<f32>,
|
||||
pub max_changed_ratio: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CaseSpec {
|
||||
pub id: String,
|
||||
pub archive: String,
|
||||
pub model: Option<String>,
|
||||
pub reference: String,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub lod: Option<usize>,
|
||||
pub group: Option<usize>,
|
||||
pub angle: Option<f32>,
|
||||
pub diff_threshold: Option<u8>,
|
||||
pub max_mean_abs: Option<f32>,
|
||||
pub max_changed_ratio: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ParityManifest {
|
||||
#[serde(default)]
|
||||
pub meta: ManifestMeta,
|
||||
#[serde(rename = "case", default)]
|
||||
pub cases: Vec<CaseSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffMetrics {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub mean_abs: f32,
|
||||
pub max_abs: u8,
|
||||
pub changed_pixels: u64,
|
||||
pub changed_ratio: f32,
|
||||
}
|
||||
|
||||
pub fn compare_images(
|
||||
reference: &RgbaImage,
|
||||
actual: &RgbaImage,
|
||||
diff_threshold: u8,
|
||||
) -> Result<DiffMetrics, String> {
|
||||
let (rw, rh) = reference.dimensions();
|
||||
let (aw, ah) = actual.dimensions();
|
||||
if rw != aw || rh != ah {
|
||||
return Err(format!(
|
||||
"image size mismatch: reference={}x{}, actual={}x{}",
|
||||
rw, rh, aw, ah
|
||||
));
|
||||
}
|
||||
|
||||
let mut diff_sum = 0u64;
|
||||
let mut max_abs = 0u8;
|
||||
let mut changed_pixels = 0u64;
|
||||
let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
|
||||
|
||||
for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
|
||||
let mut pixel_changed = false;
|
||||
for chan in 0..3 {
|
||||
let a = i16::from(ref_px[chan]);
|
||||
let b = i16::from(act_px[chan]);
|
||||
let diff = (a - b).unsigned_abs() as u8;
|
||||
diff_sum = diff_sum.saturating_add(u64::from(diff));
|
||||
if diff > max_abs {
|
||||
max_abs = diff;
|
||||
}
|
||||
if diff > diff_threshold {
|
||||
pixel_changed = true;
|
||||
}
|
||||
}
|
||||
if pixel_changed {
|
||||
changed_pixels = changed_pixels.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
let channels = pixel_count.saturating_mul(3);
|
||||
let mean_abs = if channels == 0 {
|
||||
0.0
|
||||
} else {
|
||||
diff_sum as f32 / channels as f32
|
||||
};
|
||||
let changed_ratio = if pixel_count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
changed_pixels as f32 / pixel_count as f32
|
||||
};
|
||||
|
||||
Ok(DiffMetrics {
|
||||
width: rw,
|
||||
height: rh,
|
||||
mean_abs,
|
||||
max_abs,
|
||||
changed_pixels,
|
||||
changed_ratio,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
|
||||
let (rw, rh) = reference.dimensions();
|
||||
let (aw, ah) = actual.dimensions();
|
||||
if rw != aw || rh != ah {
|
||||
return Err(format!(
|
||||
"image size mismatch: reference={}x{}, actual={}x{}",
|
||||
rw, rh, aw, ah
|
||||
));
|
||||
}
|
||||
|
||||
let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
|
||||
for (dst, (ref_px, act_px)) in out
|
||||
.pixels_mut()
|
||||
.zip(reference.pixels().zip(actual.pixels()))
|
||||
{
|
||||
let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
|
||||
let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
|
||||
let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
|
||||
*dst = Rgba([dr, dg, db, 255]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn evaluate_metrics(
|
||||
metrics: &DiffMetrics,
|
||||
max_mean_abs: f32,
|
||||
max_changed_ratio: f32,
|
||||
) -> Vec<String> {
|
||||
let mut violations = Vec::new();
|
||||
if metrics.mean_abs > max_mean_abs {
|
||||
violations.push(format!(
|
||||
"mean_abs {:.4} > allowed {:.4}",
|
||||
metrics.mean_abs, max_mean_abs
|
||||
));
|
||||
}
|
||||
if metrics.changed_ratio > max_changed_ratio {
|
||||
violations.push(format!(
|
||||
"changed_ratio {:.4}% > allowed {:.4}%",
|
||||
metrics.changed_ratio * 100.0,
|
||||
max_changed_ratio * 100.0
|
||||
));
|
||||
}
|
||||
violations
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
|
||||
let mut img = RgbaImage::new(w, h);
|
||||
for px in img.pixels_mut() {
|
||||
*px = Rgba([r, g, b, 255]);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_identical_images() {
|
||||
let ref_img = solid(4, 3, 10, 20, 30);
|
||||
let act_img = solid(4, 3, 10, 20, 30);
|
||||
let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
|
||||
assert_eq!(metrics.width, 4);
|
||||
assert_eq!(metrics.height, 3);
|
||||
assert_eq!(metrics.max_abs, 0);
|
||||
assert_eq!(metrics.changed_pixels, 0);
|
||||
assert_eq!(metrics.mean_abs, 0.0);
|
||||
assert_eq!(metrics.changed_ratio, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_detects_changes_and_thresholds() {
|
||||
let mut ref_img = solid(2, 2, 100, 100, 100);
|
||||
let mut act_img = solid(2, 2, 100, 100, 100);
|
||||
ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
|
||||
act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
|
||||
|
||||
let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
|
||||
assert_eq!(metrics.max_abs, 20);
|
||||
assert_eq!(metrics.changed_pixels, 1);
|
||||
assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
|
||||
assert!(metrics.mean_abs > 0.0);
|
||||
|
||||
let violations = evaluate_metrics(&metrics, 2.0, 0.20);
|
||||
assert_eq!(violations.len(), 1);
|
||||
assert!(violations[0].contains("changed_ratio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_diff_image_returns_per_channel_abs_diff() {
|
||||
let mut ref_img = solid(1, 1, 100, 150, 200);
|
||||
let mut act_img = solid(1, 1, 90, 180, 170);
|
||||
ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
|
||||
act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
|
||||
|
||||
let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
|
||||
let px = diff.get_pixel(0, 0);
|
||||
assert_eq!(px[0], 10);
|
||||
assert_eq!(px[1], 30);
|
||||
assert_eq!(px[2], 30);
|
||||
assert_eq!(px[3], 255);
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
use image::RgbaImage;
|
||||
use render_parity::{
|
||||
build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const DEFAULT_MANIFEST: &str = "parity/cases.toml";
|
||||
const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
|
||||
const DEFAULT_WIDTH: u32 = 1280;
|
||||
const DEFAULT_HEIGHT: u32 = 720;
|
||||
const DEFAULT_LOD: usize = 0;
|
||||
const DEFAULT_GROUP: usize = 0;
|
||||
const DEFAULT_ANGLE: f32 = 0.0;
|
||||
const DEFAULT_DIFF_THRESHOLD: u8 = 8;
|
||||
const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
|
||||
const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
|
||||
|
||||
struct Args {
|
||||
manifest: PathBuf,
|
||||
output_dir: PathBuf,
|
||||
demo_bin: Option<PathBuf>,
|
||||
keep_going: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EffectiveCase {
|
||||
id: String,
|
||||
archive: PathBuf,
|
||||
model: Option<String>,
|
||||
reference: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
lod: usize,
|
||||
group: usize,
|
||||
angle: f32,
|
||||
diff_threshold: u8,
|
||||
max_mean_abs: f32,
|
||||
max_changed_ratio: f32,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = match parse_args() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = run(args) {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
|
||||
let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
|
||||
let mut demo_bin = None;
|
||||
let mut keep_going = false;
|
||||
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--manifest" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --manifest"))?;
|
||||
manifest = PathBuf::from(value);
|
||||
}
|
||||
"--output-dir" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --output-dir"))?;
|
||||
output_dir = PathBuf::from(value);
|
||||
}
|
||||
"--demo-bin" => {
|
||||
let value = it
|
||||
.next()
|
||||
.ok_or_else(|| String::from("missing value for --demo-bin"))?;
|
||||
demo_bin = Some(PathBuf::from(value));
|
||||
}
|
||||
"--keep-going" => {
|
||||
keep_going = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Args {
|
||||
manifest,
|
||||
output_dir,
|
||||
demo_bin,
|
||||
keep_going,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
|
||||
);
|
||||
eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
|
||||
eprintln!(" --output-dir where current renders and diff images are written");
|
||||
eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
|
||||
eprintln!(" --keep-going continue all cases even after failures");
|
||||
}
|
||||
|
||||
fn run(args: Args) -> Result<(), String> {
|
||||
let workspace = workspace_root()?;
|
||||
let manifest_path = resolve_path(&workspace, &args.manifest);
|
||||
let output_dir = resolve_path(&workspace, &args.output_dir);
|
||||
let demo_bin = args
|
||||
.demo_bin
|
||||
.as_ref()
|
||||
.map(|path| resolve_path(&workspace, path));
|
||||
|
||||
let manifest_raw = fs::read_to_string(&manifest_path)
|
||||
.map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
|
||||
let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
|
||||
format!(
|
||||
"failed to parse manifest {}: {err}",
|
||||
manifest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if manifest.cases.is_empty() {
|
||||
println!(
|
||||
"render-parity: no cases in {} (nothing to validate)",
|
||||
manifest_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&output_dir).map_err(|err| {
|
||||
format!(
|
||||
"failed to create output directory {}: {err}",
|
||||
output_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest_dir = manifest_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| workspace.clone());
|
||||
|
||||
let mut failed_cases = 0usize;
|
||||
for case in &manifest.cases {
|
||||
let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
|
||||
let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
|
||||
let diff_file = output_dir
|
||||
.join("diff")
|
||||
.join(format!("{}.png", sanitize_case_id(&effective.id)));
|
||||
|
||||
let run_res = run_single_case(
|
||||
&workspace, // ensure `cargo run` executes from workspace root
|
||||
demo_bin.as_deref(),
|
||||
&effective,
|
||||
&case_file,
|
||||
&diff_file,
|
||||
);
|
||||
|
||||
match run_res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
failed_cases = failed_cases.saturating_add(1);
|
||||
eprintln!("[FAIL] {}: {}", effective.id, err);
|
||||
if !args.keep_going {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed_cases > 0 {
|
||||
return Err(format!(
|
||||
"render-parity failed: {} case(s) did not match reference frames",
|
||||
failed_cases
|
||||
));
|
||||
}
|
||||
|
||||
println!("render-parity: all cases passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_single_case(
|
||||
workspace: &Path,
|
||||
demo_bin: Option<&Path>,
|
||||
case: &EffectiveCase,
|
||||
case_file: &Path,
|
||||
diff_file: &Path,
|
||||
) -> Result<(), String> {
|
||||
run_render_capture(workspace, demo_bin, case, case_file)?;
|
||||
|
||||
let reference = load_rgba(&case.reference)?;
|
||||
let actual = load_rgba(case_file)?;
|
||||
let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
|
||||
let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
|
||||
|
||||
if violations.is_empty() {
|
||||
println!(
|
||||
"[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
|
||||
case.id,
|
||||
metrics.mean_abs,
|
||||
metrics.changed_ratio * 100.0,
|
||||
metrics.max_abs,
|
||||
metrics.width,
|
||||
metrics.height
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = diff_file.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create diff output directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let diff = build_diff_image(&reference, &actual)?;
|
||||
diff.save(diff_file)
|
||||
.map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
|
||||
|
||||
let mut details = String::new();
|
||||
for item in violations {
|
||||
if !details.is_empty() {
|
||||
details.push_str("; ");
|
||||
}
|
||||
details.push_str(&item);
|
||||
}
|
||||
Err(format!(
|
||||
"{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
|
||||
details,
|
||||
diff_file.display(),
|
||||
metrics.mean_abs,
|
||||
metrics.changed_ratio * 100.0,
|
||||
metrics.changed_pixels,
|
||||
metrics.max_abs
|
||||
))
|
||||
}
|
||||
|
||||
fn run_render_capture(
|
||||
workspace: &Path,
|
||||
demo_bin: Option<&Path>,
|
||||
case: &EffectiveCase,
|
||||
out_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create capture directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut cmd = if let Some(bin) = demo_bin {
|
||||
Command::new(bin)
|
||||
} else {
|
||||
let mut command = Command::new("cargo");
|
||||
command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
|
||||
command
|
||||
};
|
||||
|
||||
cmd.current_dir(workspace)
|
||||
.arg("--archive")
|
||||
.arg(&case.archive)
|
||||
.arg("--lod")
|
||||
.arg(case.lod.to_string())
|
||||
.arg("--group")
|
||||
.arg(case.group.to_string())
|
||||
.arg("--width")
|
||||
.arg(case.width.to_string())
|
||||
.arg("--height")
|
||||
.arg(case.height.to_string())
|
||||
.arg("--angle")
|
||||
.arg(case.angle.to_string())
|
||||
.arg("--capture")
|
||||
.arg(out_path);
|
||||
|
||||
if let Some(model) = case.model.as_deref() {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
let output = cmd.output().map_err(|err| {
|
||||
let mode = if demo_bin.is_some() {
|
||||
"parkan-render-demo"
|
||||
} else {
|
||||
"cargo run -p render-demo"
|
||||
};
|
||||
format!("failed to execute {} for case {}: {err}", mode, case.id)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status.code(),
|
||||
stdout,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
|
||||
image::open(path)
|
||||
.map_err(|err| format!("failed to load image {}: {err}", path.display()))
|
||||
.map(|img| img.to_rgba8())
|
||||
}
|
||||
|
||||
fn make_effective_case(
|
||||
meta: &ManifestMeta,
|
||||
case: &CaseSpec,
|
||||
manifest_dir: &Path,
|
||||
) -> Result<EffectiveCase, String> {
|
||||
let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
|
||||
let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
|
||||
if width == 0 || height == 0 {
|
||||
return Err(format!(
|
||||
"case '{}' has invalid dimensions {}x{}",
|
||||
case.id, width, height
|
||||
));
|
||||
}
|
||||
|
||||
let archive = resolve_path(manifest_dir, Path::new(&case.archive));
|
||||
let reference = resolve_path(manifest_dir, Path::new(&case.reference));
|
||||
if !archive.is_file() {
|
||||
return Err(format!(
|
||||
"case '{}' archive not found: {}",
|
||||
case.id,
|
||||
archive.display()
|
||||
));
|
||||
}
|
||||
if !reference.is_file() {
|
||||
return Err(format!(
|
||||
"case '{}' reference frame not found: {}",
|
||||
case.id,
|
||||
reference.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(EffectiveCase {
|
||||
id: case.id.clone(),
|
||||
archive,
|
||||
model: case.model.clone(),
|
||||
reference,
|
||||
width,
|
||||
height,
|
||||
lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
|
||||
group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
|
||||
angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
|
||||
diff_threshold: case
|
||||
.diff_threshold
|
||||
.or(meta.diff_threshold)
|
||||
.unwrap_or(DEFAULT_DIFF_THRESHOLD),
|
||||
max_mean_abs: case
|
||||
.max_mean_abs
|
||||
.or(meta.max_mean_abs)
|
||||
.unwrap_or(DEFAULT_MAX_MEAN_ABS),
|
||||
max_changed_ratio: case
|
||||
.max_changed_ratio
|
||||
.or(meta.max_changed_ratio)
|
||||
.unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_case_id(id: &str) -> String {
|
||||
id.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn workspace_root() -> Result<PathBuf, String> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.map_err(|err| format!("failed to resolve workspace root: {err}"))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "rsli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
@@ -1,58 +0,0 @@
|
||||
# rsli
|
||||
|
||||
Rust-библиотека для чтения архивов формата **RsLi**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие библиотеки из файла (`open_path`, `open_path_with`).
|
||||
- Дешифрование таблицы записей (XOR stream cipher).
|
||||
- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
|
||||
- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
|
||||
- Поиск по имени (`find`, c приведением запроса к uppercase).
|
||||
- Загрузка данных:
|
||||
- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
|
||||
|
||||
## Поддерживаемые методы упаковки
|
||||
|
||||
- `0x000` None
|
||||
- `0x020` XorOnly
|
||||
- `0x040` Lzss
|
||||
- `0x060` XorLzss
|
||||
- `0x080` LzssHuffman
|
||||
- `0x0A0` XorLzssHuffman
|
||||
- `0x100` Deflate
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/rsli/**`.
|
||||
- Сейчас в наборе: **2 архива**.
|
||||
- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
|
||||
- `0x040` (LZSS)
|
||||
- `0x100` (Deflate)
|
||||
- Для каждого архива проверяется:
|
||||
- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
|
||||
- `find`;
|
||||
- пересборка и сравнение **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
|
||||
|
||||
- Методы:
|
||||
- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
|
||||
- Спецкейсы формата:
|
||||
- AO trailer + overlay;
|
||||
- Deflate `EOF+1` (оба режима: accepted/rejected);
|
||||
- некорректные заголовки/таблицы/смещения/методы.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p rsli -- --nocapture
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
use flate2::read::DeflateDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
/// Decode raw Deflate (RFC 1951) payload.
|
||||
pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut decoder = DeflateDecoder::new(packed);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| Error::DecompressionFailed("deflate"))?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
use super::xor::XorState;
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
|
||||
pub(crate) const LZH_N: usize = 4096;
|
||||
pub(crate) const LZH_F: usize = 60;
|
||||
pub(crate) const LZH_THRESHOLD: usize = 2;
|
||||
pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
|
||||
pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1;
|
||||
pub(crate) const LZH_R: usize = LZH_T - 1;
|
||||
pub(crate) const LZH_MAX_FREQ: u16 = 0x8000;
|
||||
|
||||
/// LZSS-Huffman decompression with optional on-the-fly XOR decryption.
|
||||
pub fn lzss_huffman_decompress(
|
||||
data: &[u8],
|
||||
expected_size: usize,
|
||||
xor_key: Option<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut decoder = LzhDecoder::new(data, xor_key);
|
||||
decoder.decode(expected_size)
|
||||
}
|
||||
|
||||
struct LzhDecoder<'a> {
|
||||
bit_reader: BitReader<'a>,
|
||||
text: [u8; LZH_N],
|
||||
freq: [u16; LZH_T + 1],
|
||||
parent: [usize; LZH_T + LZH_N_CHAR],
|
||||
son: [usize; LZH_T],
|
||||
d_code: [u8; 256],
|
||||
d_len: [u8; 256],
|
||||
ring_pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> LzhDecoder<'a> {
|
||||
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
||||
let mut decoder = Self {
|
||||
bit_reader: BitReader::new(data, xor_key),
|
||||
text: [0x20u8; LZH_N],
|
||||
freq: [0u16; LZH_T + 1],
|
||||
parent: [0usize; LZH_T + LZH_N_CHAR],
|
||||
son: [0usize; LZH_T],
|
||||
d_code: [0u8; 256],
|
||||
d_len: [0u8; 256],
|
||||
ring_pos: LZH_N - LZH_F,
|
||||
};
|
||||
decoder.init_tables();
|
||||
decoder.start_huff();
|
||||
decoder
|
||||
}
|
||||
|
||||
fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
|
||||
while out.len() < expected_size {
|
||||
let c = self.decode_char()?;
|
||||
if c < 256 {
|
||||
let byte = c as u8;
|
||||
out.push(byte);
|
||||
self.text[self.ring_pos] = byte;
|
||||
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
||||
} else {
|
||||
let mut offset = self.decode_position()?;
|
||||
offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
|
||||
let mut length = c.saturating_sub(253);
|
||||
|
||||
while length > 0 && out.len() < expected_size {
|
||||
let byte = self.text[offset];
|
||||
out.push(byte);
|
||||
self.text[self.ring_pos] = byte;
|
||||
self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
|
||||
offset = (offset + 1) & (LZH_N - 1);
|
||||
length -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.len() != expected_size {
|
||||
return Err(Error::DecompressionFailed("lzss-huffman"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn init_tables(&mut self) {
|
||||
let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
|
||||
let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
|
||||
|
||||
let mut group_index = 0u8;
|
||||
let mut idx = 0usize;
|
||||
let mut run = 32usize;
|
||||
for count in d_code_group_counts {
|
||||
for _ in 0..count {
|
||||
for _ in 0..run {
|
||||
self.d_code[idx] = group_index;
|
||||
idx += 1;
|
||||
}
|
||||
group_index = group_index.wrapping_add(1);
|
||||
}
|
||||
run >>= 1;
|
||||
}
|
||||
|
||||
let mut len = 3u8;
|
||||
idx = 0;
|
||||
for count in d_len_group_counts {
|
||||
for _ in 0..count {
|
||||
self.d_len[idx] = len;
|
||||
idx += 1;
|
||||
}
|
||||
len = len.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_huff(&mut self) {
|
||||
for i in 0..LZH_N_CHAR {
|
||||
self.freq[i] = 1;
|
||||
self.son[i] = i + LZH_T;
|
||||
self.parent[i + LZH_T] = i;
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let mut j = LZH_N_CHAR;
|
||||
while j <= LZH_R {
|
||||
self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||
self.son[j] = i;
|
||||
self.parent[i] = j;
|
||||
self.parent[i + 1] = j;
|
||||
i += 2;
|
||||
j += 1;
|
||||
}
|
||||
|
||||
self.freq[LZH_T] = u16::MAX;
|
||||
self.parent[LZH_R] = 0;
|
||||
}
|
||||
|
||||
fn decode_char(&mut self) -> Result<usize> {
|
||||
let mut node = self.son[LZH_R];
|
||||
while node < LZH_T {
|
||||
let bit = usize::from(self.bit_reader.read_bit()?);
|
||||
let branch = node
|
||||
.checked_add(bit)
|
||||
.ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
|
||||
node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
|
||||
"lzss-huffman tree out of bounds",
|
||||
))?;
|
||||
}
|
||||
|
||||
let c = node - LZH_T;
|
||||
self.update(c);
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn decode_position(&mut self) -> Result<usize> {
|
||||
let i = self.bit_reader.read_bits(8)? as usize;
|
||||
let mut c = usize::from(self.d_code[i]) << 6;
|
||||
let mut j = usize::from(self.d_len[i]).saturating_sub(2);
|
||||
|
||||
while j > 0 {
|
||||
j -= 1;
|
||||
c |= usize::from(self.bit_reader.read_bit()?) << j;
|
||||
}
|
||||
|
||||
Ok(c | (i & 0x3F))
|
||||
}
|
||||
|
||||
fn update(&mut self, c: usize) {
|
||||
if self.freq[LZH_R] == LZH_MAX_FREQ {
|
||||
self.reconstruct();
|
||||
}
|
||||
|
||||
let mut current = self.parent[c + LZH_T];
|
||||
loop {
|
||||
self.freq[current] = self.freq[current].saturating_add(1);
|
||||
let freq = self.freq[current];
|
||||
|
||||
if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
|
||||
let mut swap_idx = current + 1;
|
||||
while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
|
||||
swap_idx += 1;
|
||||
}
|
||||
|
||||
self.freq.swap(current, swap_idx);
|
||||
|
||||
let left = self.son[current];
|
||||
let right = self.son[swap_idx];
|
||||
self.son[current] = right;
|
||||
self.son[swap_idx] = left;
|
||||
|
||||
self.parent[left] = swap_idx;
|
||||
if left < LZH_T {
|
||||
self.parent[left + 1] = swap_idx;
|
||||
}
|
||||
|
||||
self.parent[right] = current;
|
||||
if right < LZH_T {
|
||||
self.parent[right + 1] = current;
|
||||
}
|
||||
|
||||
current = swap_idx;
|
||||
}
|
||||
|
||||
current = self.parent[current];
|
||||
if current == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconstruct(&mut self) {
|
||||
let mut j = 0usize;
|
||||
for i in 0..LZH_T {
|
||||
if self.son[i] >= LZH_T {
|
||||
self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
|
||||
self.son[j] = self.son[i];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let mut current = LZH_N_CHAR;
|
||||
while current < LZH_T {
|
||||
let sum = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||
self.freq[current] = sum;
|
||||
|
||||
let mut insert_at = current;
|
||||
while insert_at > 0 && sum < self.freq[insert_at - 1] {
|
||||
insert_at -= 1;
|
||||
}
|
||||
|
||||
for move_idx in (insert_at..current).rev() {
|
||||
self.freq[move_idx + 1] = self.freq[move_idx];
|
||||
self.son[move_idx + 1] = self.son[move_idx];
|
||||
}
|
||||
|
||||
self.freq[insert_at] = sum;
|
||||
self.son[insert_at] = i;
|
||||
|
||||
i += 2;
|
||||
current += 1;
|
||||
}
|
||||
|
||||
for idx in 0..LZH_T {
|
||||
let node = self.son[idx];
|
||||
self.parent[node] = idx;
|
||||
if node < LZH_T {
|
||||
self.parent[node + 1] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
self.freq[LZH_T] = u16::MAX;
|
||||
self.parent[LZH_R] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
byte_pos: usize,
|
||||
bit_mask: u8,
|
||||
current_byte: u8,
|
||||
xor_state: Option<XorState>,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
|
||||
Self {
|
||||
data,
|
||||
byte_pos: 0,
|
||||
bit_mask: 0x80,
|
||||
current_byte: 0,
|
||||
xor_state: xor_key.map(XorState::new),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bit(&mut self) -> Result<u8> {
|
||||
if self.bit_mask == 0x80 {
|
||||
let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
|
||||
return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF"));
|
||||
};
|
||||
if let Some(state) = &mut self.xor_state {
|
||||
byte = state.decrypt_byte(byte);
|
||||
}
|
||||
self.current_byte = byte;
|
||||
}
|
||||
|
||||
let bit = if (self.current_byte & self.bit_mask) != 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.bit_mask >>= 1;
|
||||
if self.bit_mask == 0 {
|
||||
self.bit_mask = 0x80;
|
||||
self.byte_pos = self.byte_pos.saturating_add(1);
|
||||
}
|
||||
Ok(bit)
|
||||
}
|
||||
|
||||
fn read_bits(&mut self, bits: usize) -> Result<u32> {
|
||||
let mut value = 0u32;
|
||||
for _ in 0..bits {
|
||||
value = (value << 1) | u32::from(self.read_bit()?);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use super::xor::XorState;
|
||||
use crate::error::Error;
|
||||
use crate::Result;
|
||||
|
||||
/// Simple LZSS decompression with optional on-the-fly XOR decryption
|
||||
pub fn lzss_decompress_simple(
|
||||
data: &[u8],
|
||||
expected_size: usize,
|
||||
xor_key: Option<u16>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut ring = [0x20u8; 0x1000];
|
||||
let mut ring_pos = 0xFEEusize;
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
let mut in_pos = 0usize;
|
||||
|
||||
let mut control = 0u8;
|
||||
let mut bits_left = 0u8;
|
||||
|
||||
// XOR state for on-the-fly decryption
|
||||
let mut xor_state = xor_key.map(XorState::new);
|
||||
|
||||
// Helper to read byte with optional XOR decryption
|
||||
let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> {
|
||||
let encrypted = data.get(pos).copied()?;
|
||||
Some(if let Some(ref mut s) = state {
|
||||
s.decrypt_byte(encrypted)
|
||||
} else {
|
||||
encrypted
|
||||
})
|
||||
};
|
||||
|
||||
while out.len() < expected_size {
|
||||
if bits_left == 0 {
|
||||
let byte = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
control = byte;
|
||||
in_pos += 1;
|
||||
bits_left = 8;
|
||||
}
|
||||
|
||||
if (control & 1) != 0 {
|
||||
let byte = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
in_pos += 1;
|
||||
|
||||
out.push(byte);
|
||||
ring[ring_pos] = byte;
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF;
|
||||
} else {
|
||||
let low = read_byte(in_pos, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
let high = read_byte(in_pos + 1, &mut xor_state)
|
||||
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
|
||||
in_pos += 2;
|
||||
|
||||
let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
|
||||
let length = usize::from((high & 0x0F) + 3);
|
||||
|
||||
for step in 0..length {
|
||||
let byte = ring[(offset + step) & 0x0FFF];
|
||||
out.push(byte);
|
||||
ring[ring_pos] = byte;
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF;
|
||||
if out.len() >= expected_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
control >>= 1;
|
||||
bits_left -= 1;
|
||||
}
|
||||
|
||||
if out.len() != expected_size {
|
||||
return Err(Error::DecompressionFailed("lzss-simple"));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
pub mod deflate;
|
||||
pub mod lzh;
|
||||
pub mod lzss;
|
||||
pub mod xor;
|
||||
|
||||
pub use deflate::decode_deflate;
|
||||
pub use lzh::lzss_huffman_decompress;
|
||||
pub use lzss::lzss_decompress_simple;
|
||||
pub use xor::{xor_stream, XorState};
|
||||
@@ -1,29 +0,0 @@
|
||||
/// XOR cipher state for RsLi format
|
||||
pub struct XorState {
|
||||
lo: u8,
|
||||
hi: u8,
|
||||
}
|
||||
|
||||
impl XorState {
|
||||
/// Create new XOR state from 16-bit key
|
||||
pub fn new(key16: u16) -> Self {
|
||||
Self {
|
||||
lo: (key16 & 0xFF) as u8,
|
||||
hi: ((key16 >> 8) & 0xFF) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a single byte and update state
|
||||
pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
|
||||
self.lo = self.hi ^ self.lo.wrapping_shl(1);
|
||||
let decrypted = encrypted ^ self.lo;
|
||||
self.hi = self.lo ^ (self.hi >> 1);
|
||||
decrypted
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt entire buffer with XOR stream cipher
|
||||
pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
|
||||
let mut state = XorState::new(key16);
|
||||
data.iter().map(|&b| state.decrypt_byte(b)).collect()
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
|
||||
InvalidMagic {
|
||||
got: [u8; 2],
|
||||
},
|
||||
UnsupportedVersion {
|
||||
got: u8,
|
||||
},
|
||||
InvalidEntryCount {
|
||||
got: i16,
|
||||
},
|
||||
TooManyEntries {
|
||||
got: usize,
|
||||
},
|
||||
|
||||
EntryTableOutOfBounds {
|
||||
table_offset: u64,
|
||||
table_len: u64,
|
||||
file_len: u64,
|
||||
},
|
||||
EntryTableDecryptFailed,
|
||||
CorruptEntryTable(&'static str),
|
||||
|
||||
EntryIdOutOfRange {
|
||||
id: u32,
|
||||
entry_count: u32,
|
||||
},
|
||||
EntryDataOutOfBounds {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
AoTrailerInvalid,
|
||||
MediaOverlayOutOfBounds {
|
||||
overlay: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
|
||||
UnsupportedMethod {
|
||||
raw: u32,
|
||||
},
|
||||
PackedSizePastEof {
|
||||
id: u32,
|
||||
offset: u64,
|
||||
packed_size: u32,
|
||||
file_len: u64,
|
||||
},
|
||||
DeflateEofPlusOneQuirkRejected {
|
||||
id: u32,
|
||||
},
|
||||
|
||||
DecompressionFailed(&'static str),
|
||||
OutputSizeMismatch {
|
||||
expected: u32,
|
||||
got: u32,
|
||||
},
|
||||
|
||||
IntegerOverflow,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
|
||||
Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
|
||||
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
|
||||
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
|
||||
Error::EntryTableOutOfBounds {
|
||||
table_offset,
|
||||
table_len,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
|
||||
),
|
||||
Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"),
|
||||
Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"),
|
||||
Error::EntryIdOutOfRange { id, entry_count } => {
|
||||
write!(f, "entry id out of range: id={id}, count={entry_count}")
|
||||
}
|
||||
Error::EntryDataOutOfBounds {
|
||||
id,
|
||||
offset,
|
||||
size,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
|
||||
),
|
||||
Error::AoTrailerInvalid => write!(f, "invalid AO trailer"),
|
||||
Error::MediaOverlayOutOfBounds { overlay, file_len } => {
|
||||
write!(
|
||||
f,
|
||||
"media overlay out of bounds: overlay={overlay}, file={file_len}"
|
||||
)
|
||||
}
|
||||
Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
|
||||
Error::PackedSizePastEof {
|
||||
id,
|
||||
offset,
|
||||
packed_size,
|
||||
file_len,
|
||||
} => write!(
|
||||
f,
|
||||
"packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
|
||||
),
|
||||
Error::DeflateEofPlusOneQuirkRejected { id } => {
|
||||
write!(f, "deflate EOF+1 quirk rejected for entry {id}")
|
||||
}
|
||||
Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"),
|
||||
Error::OutputSizeMismatch { expected, got } => {
|
||||
write!(f, "output size mismatch: expected={expected}, got={got}")
|
||||
}
|
||||
Error::IntegerOverflow => write!(f, "integer overflow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
pub mod compress;
|
||||
pub mod error;
|
||||
pub mod parse;
|
||||
|
||||
use crate::compress::{
|
||||
decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream,
|
||||
};
|
||||
use crate::error::Error;
|
||||
use crate::parse::{c_name_bytes, cmp_c_string, parse_library};
|
||||
use common::{OutputBuffer, ResourceData};
|
||||
use std::cmp::Ordering;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenOptions {
|
||||
pub allow_ao_trailer: bool,
|
||||
pub allow_deflate_eof_plus_one: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_ao_trailer: true,
|
||||
allow_deflate_eof_plus_one: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LibraryHeader {
|
||||
pub raw: [u8; 32],
|
||||
pub magic: [u8; 2],
|
||||
pub reserved: u8,
|
||||
pub version: u8,
|
||||
pub entry_count: i16,
|
||||
pub presorted_flag: u16,
|
||||
pub xor_seed: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AoTrailer {
|
||||
pub raw: [u8; 6],
|
||||
pub overlay: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Library {
|
||||
bytes: Arc<[u8]>,
|
||||
entries: Vec<EntryRecord>,
|
||||
header: LibraryHeader,
|
||||
ao_trailer: Option<AoTrailer>,
|
||||
#[cfg(test)]
|
||||
pub(crate) table_plain_original: Vec<u8>,
|
||||
#[cfg(test)]
|
||||
pub(crate) source_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct EntryId(pub u32);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub name: String,
|
||||
pub flags: i32,
|
||||
pub method: PackMethod,
|
||||
pub data_offset: u64,
|
||||
pub packed_size: u32,
|
||||
pub unpacked_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PackMethod {
|
||||
None,
|
||||
XorOnly,
|
||||
Lzss,
|
||||
XorLzss,
|
||||
LzssHuffman,
|
||||
XorLzssHuffman,
|
||||
Deflate,
|
||||
Unknown(u32),
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryRef<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct EntryInspect<'a> {
|
||||
pub id: EntryId,
|
||||
pub meta: &'a EntryMeta,
|
||||
pub name_raw: &'a [u8; 12],
|
||||
pub service_tail: &'a [u8; 4],
|
||||
pub sort_to_original: i16,
|
||||
pub data_offset_raw: u32,
|
||||
}
|
||||
|
||||
pub struct PackedResource {
|
||||
pub meta: EntryMeta,
|
||||
pub packed: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EntryRecord {
|
||||
pub(crate) meta: EntryMeta,
|
||||
pub(crate) name_raw: [u8; 12],
|
||||
pub(crate) service_tail: [u8; 4],
|
||||
pub(crate) sort_to_original: i16,
|
||||
pub(crate) key16: u16,
|
||||
pub(crate) data_offset_raw: u32,
|
||||
pub(crate) packed_size_declared: u32,
|
||||
pub(crate) packed_size_available: usize,
|
||||
pub(crate) effective_offset: usize,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_path_with(path, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
||||
parse_library(arc, opts)
|
||||
}
|
||||
|
||||
pub fn header(&self) -> &LibraryHeader {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
|
||||
self.ao_trailer.as_ref()
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryRef {
|
||||
id: EntryId(id),
|
||||
meta: &entry.meta,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryInspect {
|
||||
id: EntryId(id),
|
||||
meta: &entry.meta,
|
||||
name_raw: &entry.name_raw,
|
||||
service_tail: &entry.service_tail,
|
||||
sort_to_original: entry.sort_to_original,
|
||||
data_offset_raw: entry.data_offset_raw,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const MAX_INLINE_NAME: usize = 12;
|
||||
|
||||
// Fast path: use stack allocation for short ASCII names (95% of cases)
|
||||
if name.len() <= MAX_INLINE_NAME && name.is_ascii() {
|
||||
let mut buf = [0u8; MAX_INLINE_NAME];
|
||||
for (i, &b) in name.as_bytes().iter().enumerate() {
|
||||
buf[i] = b.to_ascii_uppercase();
|
||||
}
|
||||
return self.find_impl(&buf[..name.len()]);
|
||||
}
|
||||
|
||||
// Slow path: heap allocation for long or non-ASCII names
|
||||
let query = name.to_ascii_uppercase();
|
||||
self.find_impl(query.as_bytes())
|
||||
}
|
||||
|
||||
fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
|
||||
// Binary search
|
||||
let mut low = 0usize;
|
||||
let mut high = self.entries.len();
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2;
|
||||
let idx = self.entries[mid].sort_to_original;
|
||||
if idx < 0 {
|
||||
break;
|
||||
}
|
||||
let idx = usize::try_from(idx).ok()?;
|
||||
if idx >= self.entries.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw));
|
||||
match cmp {
|
||||
Ordering::Less => high = mid,
|
||||
Ordering::Greater => low = mid + 1,
|
||||
Ordering::Equal => {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
return Some(EntryId(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linear fallback search
|
||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
|
||||
let id = u32::try_from(idx).ok()?;
|
||||
Some(EntryId(id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryRef {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||
let idx = usize::try_from(id.0).ok()?;
|
||||
let entry = self.entries.get(idx)?;
|
||||
Some(EntryInspect {
|
||||
id,
|
||||
meta: &entry.meta,
|
||||
name_raw: &entry.name_raw,
|
||||
service_tail: &entry.service_tail,
|
||||
sort_to_original: entry.sort_to_original,
|
||||
data_offset_raw: entry.data_offset_raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
let packed = self.packed_slice(id, entry)?;
|
||||
decode_payload(
|
||||
packed,
|
||||
entry.meta.method,
|
||||
entry.key16,
|
||||
entry.meta.unpacked_size,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
||||
let decoded = self.load(id)?;
|
||||
out.write_exact(&decoded)?;
|
||||
Ok(decoded.len())
|
||||
}
|
||||
|
||||
pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
let packed = self.packed_slice(id, entry)?.to_vec();
|
||||
Ok(PackedResource {
|
||||
meta: entry.meta.clone(),
|
||||
packed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unpack(&self, packed: &PackedResource) -> Result<Vec<u8>> {
|
||||
let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0);
|
||||
|
||||
let method = packed.meta.method;
|
||||
if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"cannot resolve XOR key for packed resource",
|
||||
));
|
||||
}
|
||||
|
||||
decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size)
|
||||
}
|
||||
|
||||
pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||
let entry = self.entry_by_id(id)?;
|
||||
if entry.meta.method == PackMethod::None {
|
||||
let packed = self.packed_slice(id, entry)?;
|
||||
let size =
|
||||
usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
|
||||
if packed.len() < size {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: entry.meta.unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
return Ok(ResourceData::Borrowed(&packed[..size]));
|
||||
}
|
||||
Ok(ResourceData::Owned(self.load(id)?))
|
||||
}
|
||||
|
||||
fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> {
|
||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
||||
self.entries
|
||||
.get(idx)
|
||||
.ok_or_else(|| Error::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: saturating_u32_len(self.entries.len()),
|
||||
})
|
||||
}
|
||||
|
||||
fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> {
|
||||
let start = entry.effective_offset;
|
||||
let end = start
|
||||
.checked_add(entry.packed_size_available)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
self.bytes
|
||||
.get(start..end)
|
||||
.ok_or(Error::EntryDataOutOfBounds {
|
||||
id: id.0,
|
||||
offset: u64::try_from(start).unwrap_or(u64::MAX),
|
||||
size: entry.packed_size_declared,
|
||||
file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option<u16> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.meta.name == meta.name
|
||||
&& entry.meta.flags == meta.flags
|
||||
&& entry.meta.data_offset == meta.data_offset
|
||||
&& entry.meta.packed_size == meta.packed_size
|
||||
&& entry.meta.unpacked_size == meta.unpacked_size
|
||||
&& entry.meta.method == meta.method
|
||||
})
|
||||
.map(|entry| entry.key16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
|
||||
let trailer_len = usize::from(self.ao_trailer.is_some()) * 6;
|
||||
let pre_trailer_size = self
|
||||
.source_size
|
||||
.checked_sub(trailer_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
|
||||
let count = self.entries.len();
|
||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
||||
let table_end = 32usize
|
||||
.checked_add(table_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if pre_trailer_size < table_end {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
||||
file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; pre_trailer_size];
|
||||
out[0..32].copy_from_slice(&self.header.raw);
|
||||
let encrypted_table = xor_stream(
|
||||
&self.table_plain_original,
|
||||
(self.header.xor_seed & 0xFFFF) as u16,
|
||||
);
|
||||
out[32..table_end].copy_from_slice(&encrypted_table);
|
||||
|
||||
let mut occupied = vec![false; pre_trailer_size];
|
||||
for byte in occupied.iter_mut().take(table_end) {
|
||||
*byte = true;
|
||||
}
|
||||
|
||||
for (idx, entry) in self.entries.iter().enumerate() {
|
||||
let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
|
||||
let packed = self.load_packed(EntryId(id))?.packed;
|
||||
let start =
|
||||
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
|
||||
for (offset, byte) in packed.iter().copied().enumerate() {
|
||||
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
|
||||
if pos >= out.len() {
|
||||
return Err(Error::PackedSizePastEof {
|
||||
id,
|
||||
offset: u64::from(entry.data_offset_raw),
|
||||
packed_size: entry.packed_size_declared,
|
||||
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
if occupied[pos] && out[pos] != byte {
|
||||
return Err(Error::CorruptEntryTable("packed payload overlap conflict"));
|
||||
}
|
||||
out[pos] = byte;
|
||||
occupied[pos] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(trailer) = &self.ao_trailer {
|
||||
out.extend_from_slice(&trailer.raw);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_payload(
|
||||
packed: &[u8],
|
||||
method: PackMethod,
|
||||
key16: u16,
|
||||
unpacked_size: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
let out = match method {
|
||||
PackMethod::None => {
|
||||
if packed.len() < expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
packed[..expected].to_vec()
|
||||
}
|
||||
PackMethod::XorOnly => {
|
||||
if packed.len() < expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
xor_stream(&packed[..expected], key16)
|
||||
}
|
||||
PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
||||
PackMethod::XorLzss => {
|
||||
// Optimized: XOR on-the-fly during decompression instead of creating temp buffer
|
||||
lzss_decompress_simple(packed, expected, Some(key16))?
|
||||
}
|
||||
PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?,
|
||||
PackMethod::XorLzssHuffman => {
|
||||
// Optimized: XOR on-the-fly during decompression
|
||||
lzss_huffman_decompress(packed, expected, Some(key16))?
|
||||
}
|
||||
PackMethod::Deflate => decode_deflate(packed)?,
|
||||
PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }),
|
||||
};
|
||||
|
||||
if out.len() != expected {
|
||||
return Err(Error::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(out.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn needs_xor_key(method: PackMethod) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman
|
||||
)
|
||||
}
|
||||
|
||||
fn saturating_u32_len(len: usize) -> u32 {
|
||||
u32::try_from(len).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,278 +0,0 @@
|
||||
use crate::compress::xor::xor_stream;
|
||||
use crate::error::Error;
|
||||
use crate::{
|
||||
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result,
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
||||
if bytes.len() < 32 {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
table_len: 0,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let mut header_raw = [0u8; 32];
|
||||
header_raw.copy_from_slice(&bytes[0..32]);
|
||||
|
||||
let mut magic = [0u8; 2];
|
||||
magic.copy_from_slice(&bytes[0..2]);
|
||||
if &magic != b"NL" {
|
||||
let mut got = [0u8; 2];
|
||||
got.copy_from_slice(&bytes[0..2]);
|
||||
return Err(Error::InvalidMagic { got });
|
||||
}
|
||||
let reserved = bytes[2];
|
||||
let version = bytes[3];
|
||||
if version != 0x01 {
|
||||
return Err(Error::UnsupportedVersion { got: version });
|
||||
}
|
||||
|
||||
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
|
||||
if entry_count < 0 {
|
||||
return Err(Error::InvalidEntryCount { got: entry_count });
|
||||
}
|
||||
let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
// Validate entry_count fits in u32 (required for EntryId)
|
||||
if count > u32::MAX as usize {
|
||||
return Err(Error::TooManyEntries { got: count });
|
||||
}
|
||||
|
||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
let header = LibraryHeader {
|
||||
raw: header_raw,
|
||||
magic,
|
||||
reserved,
|
||||
version,
|
||||
entry_count,
|
||||
presorted_flag,
|
||||
xor_seed,
|
||||
};
|
||||
|
||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
||||
let table_offset = 32usize;
|
||||
let table_end = table_offset
|
||||
.checked_add(table_len)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if table_end > bytes.len() {
|
||||
return Err(Error::EntryTableOutOfBounds {
|
||||
table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?,
|
||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
let table_enc = &bytes[table_offset..table_end];
|
||||
let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16);
|
||||
if table_plain_original.len() != table_len {
|
||||
return Err(Error::EntryTableDecryptFailed);
|
||||
}
|
||||
|
||||
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
|
||||
|
||||
let mut entries = Vec::with_capacity(count);
|
||||
for idx in 0..count {
|
||||
let row = &table_plain_original[idx * 32..(idx + 1) * 32];
|
||||
|
||||
let mut name_raw = [0u8; 12];
|
||||
name_raw.copy_from_slice(&row[0..12]);
|
||||
let mut service_tail = [0u8; 4];
|
||||
service_tail.copy_from_slice(&row[12..16]);
|
||||
|
||||
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
||||
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
||||
|
||||
let method_raw = (flags_signed as u16 as u32) & 0x1E0;
|
||||
let method = parse_method(method_raw);
|
||||
|
||||
let effective_offset_u64 = u64::from(data_offset_raw)
|
||||
.checked_add(u64::from(overlay))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let effective_offset =
|
||||
usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
let packed_size_usize =
|
||||
usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?;
|
||||
let mut packed_size_available = packed_size_usize;
|
||||
|
||||
let end = effective_offset_u64
|
||||
.checked_add(u64::from(packed_size_declared))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||
|
||||
if end > file_len_u64 {
|
||||
if method_raw == 0x100 && end == file_len_u64 + 1 {
|
||||
if opts.allow_deflate_eof_plus_one {
|
||||
packed_size_available = packed_size_available
|
||||
.checked_sub(1)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
} else {
|
||||
return Err(Error::DeflateEofPlusOneQuirkRejected {
|
||||
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return Err(Error::PackedSizePastEof {
|
||||
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||
offset: effective_offset_u64,
|
||||
packed_size: packed_size_declared,
|
||||
file_len: file_len_u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let available_end = effective_offset
|
||||
.checked_add(packed_size_available)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if available_end > bytes.len() {
|
||||
return Err(Error::EntryDataOutOfBounds {
|
||||
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||
offset: effective_offset_u64,
|
||||
size: packed_size_declared,
|
||||
file_len: file_len_u64,
|
||||
});
|
||||
}
|
||||
|
||||
let name = decode_name(c_name_bytes(&name_raw));
|
||||
|
||||
entries.push(EntryRecord {
|
||||
meta: EntryMeta {
|
||||
name,
|
||||
flags: i32::from(flags_signed),
|
||||
method,
|
||||
data_offset: effective_offset_u64,
|
||||
packed_size: packed_size_declared,
|
||||
unpacked_size,
|
||||
},
|
||||
name_raw,
|
||||
service_tail,
|
||||
sort_to_original,
|
||||
key16: sort_to_original as u16,
|
||||
data_offset_raw,
|
||||
packed_size_declared,
|
||||
packed_size_available,
|
||||
effective_offset,
|
||||
});
|
||||
}
|
||||
|
||||
if presorted_flag == 0xABBA {
|
||||
let mut seen = vec![false; count];
|
||||
for entry in &entries {
|
||||
let idx = i32::from(entry.sort_to_original);
|
||||
if idx < 0 {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a valid permutation index",
|
||||
));
|
||||
}
|
||||
let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
|
||||
if idx >= count {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a valid permutation index",
|
||||
));
|
||||
}
|
||||
if seen[idx] {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
));
|
||||
}
|
||||
seen[idx] = true;
|
||||
}
|
||||
if seen.iter().any(|value| !*value) {
|
||||
return Err(Error::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let mut sorted: Vec<usize> = (0..count).collect();
|
||||
sorted.sort_by(|a, b| {
|
||||
cmp_c_string(
|
||||
c_name_bytes(&entries[*a].name_raw),
|
||||
c_name_bytes(&entries[*b].name_raw),
|
||||
)
|
||||
});
|
||||
for (idx, entry) in entries.iter_mut().enumerate() {
|
||||
entry.sort_to_original =
|
||||
i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?;
|
||||
entry.key16 = entry.sort_to_original as u16;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let source_size = bytes.len();
|
||||
|
||||
Ok(Library {
|
||||
bytes,
|
||||
entries,
|
||||
header,
|
||||
ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
|
||||
#[cfg(test)]
|
||||
table_plain_original,
|
||||
#[cfg(test)]
|
||||
source_size,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> {
|
||||
if !allow || bytes.len() < 6 {
|
||||
return Ok((0, None));
|
||||
}
|
||||
|
||||
if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
||||
return Ok((0, None));
|
||||
}
|
||||
|
||||
let mut trailer = [0u8; 6];
|
||||
trailer.copy_from_slice(&bytes[bytes.len() - 6..]);
|
||||
let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]);
|
||||
|
||||
if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? {
|
||||
return Err(Error::MediaOverlayOutOfBounds {
|
||||
overlay,
|
||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((overlay, Some(trailer)))
|
||||
}
|
||||
|
||||
pub fn parse_method(raw: u32) -> PackMethod {
|
||||
match raw {
|
||||
0x000 => PackMethod::None,
|
||||
0x020 => PackMethod::XorOnly,
|
||||
0x040 => PackMethod::Lzss,
|
||||
0x060 => PackMethod::XorLzss,
|
||||
0x080 => PackMethod::LzssHuffman,
|
||||
0x0A0 => PackMethod::XorLzssHuffman,
|
||||
0x100 => PackMethod::Deflate,
|
||||
other => PackMethod::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|b| char::from(*b)).collect()
|
||||
}
|
||||
|
||||
pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
}
|
||||
|
||||
pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering {
|
||||
let min_len = a.len().min(b.len());
|
||||
let mut idx = 0usize;
|
||||
while idx < min_len {
|
||||
if a[idx] != b[idx] {
|
||||
return a[idx].cmp(&b[idx]);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
a.len().cmp(&b.len())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "terrain-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nres = { path = "../nres" }
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
@@ -1,281 +0,0 @@
|
||||
use nres::Archive;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
pub const TERRAIN_UV_SCALE: f32 = 1024.0;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Nres(nres::error::Error),
|
||||
MissingChunk(&'static str),
|
||||
InvalidChunkSize {
|
||||
label: &'static str,
|
||||
size: usize,
|
||||
stride: usize,
|
||||
},
|
||||
VertexCountOverflow {
|
||||
count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Nres(err) => write!(f, "{err}"),
|
||||
Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
|
||||
Self::InvalidChunkSize {
|
||||
label,
|
||||
size,
|
||||
stride,
|
||||
} => write!(
|
||||
f,
|
||||
"invalid chunk size for {label}: {size} (must be divisible by {stride})"
|
||||
),
|
||||
Self::VertexCountOverflow { count } => {
|
||||
write!(f, "terrain vertex count {count} exceeds u16 range")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Nres(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<nres::error::Error> for Error {
|
||||
fn from(value: nres::error::Error) -> Self {
|
||||
Self::Nres(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TerrainMesh {
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
pub uv0: Vec<[f32; 2]>,
|
||||
pub faces: Vec<TerrainFace>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct TerrainFace {
|
||||
pub indices: [u16; 3],
|
||||
pub flags: u32,
|
||||
pub material_tag: u16,
|
||||
pub aux_tag: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TerrainRenderMesh {
|
||||
pub vertices: Vec<TerrainRenderVertex>,
|
||||
pub indices: Vec<u16>,
|
||||
pub face_count_raw: usize,
|
||||
pub face_count_kept: usize,
|
||||
pub face_count_dropped_invalid: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct TerrainRenderVertex {
|
||||
pub position: [f32; 3],
|
||||
pub uv0: [f32; 2],
|
||||
}
|
||||
|
||||
pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
|
||||
let archive = Archive::open_path(path.as_ref())?;
|
||||
|
||||
let positions_entry = archive
|
||||
.entries()
|
||||
.find(|entry| entry.meta.kind == 3)
|
||||
.ok_or(Error::MissingChunk("type=3 (positions)"))?;
|
||||
let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
|
||||
let faces_entry = archive
|
||||
.entries()
|
||||
.find(|entry| entry.meta.kind == 21)
|
||||
.ok_or(Error::MissingChunk("type=21 (faces)"))?;
|
||||
|
||||
let positions_payload = archive.read(positions_entry.id)?.into_owned();
|
||||
if positions_payload.len() % 12 != 0 {
|
||||
return Err(Error::InvalidChunkSize {
|
||||
label: "type=3 (positions)",
|
||||
size: positions_payload.len(),
|
||||
stride: 12,
|
||||
});
|
||||
}
|
||||
|
||||
let mut positions = Vec::with_capacity(positions_payload.len() / 12);
|
||||
for chunk in positions_payload.chunks_exact(12) {
|
||||
let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||
let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
|
||||
let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
|
||||
positions.push([x, y, z]);
|
||||
}
|
||||
|
||||
let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
|
||||
if let Some(uv_entry) = uv_entry {
|
||||
let uv_payload = archive.read(uv_entry.id)?.into_owned();
|
||||
if uv_payload.len() % 4 != 0 {
|
||||
return Err(Error::InvalidChunkSize {
|
||||
label: "type=5 (uv)",
|
||||
size: uv_payload.len(),
|
||||
stride: 4,
|
||||
});
|
||||
}
|
||||
let uv_count = uv_payload.len() / 4;
|
||||
for idx in 0..uv_count.min(uv0.len()) {
|
||||
let off = idx * 4;
|
||||
let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
|
||||
let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
|
||||
uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
|
||||
}
|
||||
}
|
||||
|
||||
let face_payload = archive.read(faces_entry.id)?.into_owned();
|
||||
if face_payload.len() % 28 != 0 {
|
||||
return Err(Error::InvalidChunkSize {
|
||||
label: "type=21 (faces)",
|
||||
size: face_payload.len(),
|
||||
stride: 28,
|
||||
});
|
||||
}
|
||||
|
||||
let mut faces = Vec::with_capacity(face_payload.len() / 28);
|
||||
for chunk in face_payload.chunks_exact(28) {
|
||||
let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||
let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
|
||||
let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
|
||||
let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
|
||||
let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
|
||||
let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
|
||||
if usize::from(i0) >= positions.len()
|
||||
|| usize::from(i1) >= positions.len()
|
||||
|| usize::from(i2) >= positions.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
faces.push(TerrainFace {
|
||||
indices: [i0, i1, i2],
|
||||
flags,
|
||||
material_tag,
|
||||
aux_tag,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(TerrainMesh {
|
||||
positions,
|
||||
uv0,
|
||||
faces,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
|
||||
if mesh.positions.len() > usize::from(u16::MAX) + 1 {
|
||||
return Err(Error::VertexCountOverflow {
|
||||
count: mesh.positions.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let vertices = mesh
|
||||
.positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &position)| TerrainRenderVertex {
|
||||
position,
|
||||
uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
|
||||
for face in &mesh.faces {
|
||||
indices.extend_from_slice(&face.indices);
|
||||
}
|
||||
|
||||
Ok(TerrainRenderMesh {
|
||||
vertices,
|
||||
indices,
|
||||
face_count_raw: mesh.faces.len(),
|
||||
face_count_kept: mesh.faces.len(),
|
||||
face_count_dropped_invalid: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn game_root() -> Option<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("Parkan - Iron Strategy");
|
||||
root.is_dir().then_some(root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_known_land_mesh() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let land = root
|
||||
.join("DATA")
|
||||
.join("MAPS")
|
||||
.join("Tut_1")
|
||||
.join("Land.msh");
|
||||
if !land.is_file() {
|
||||
eprintln!("skipping missing sample {}", land.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mesh = load_land_mesh(&land)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
|
||||
assert!(mesh.positions.len() > 1000);
|
||||
assert!(mesh.faces.len() > 1000);
|
||||
|
||||
let render = build_render_mesh(&mesh).expect("failed to build render mesh");
|
||||
assert_eq!(render.vertices.len(), mesh.positions.len());
|
||||
assert_eq!(render.indices.len(), mesh.faces.len() * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_all_retail_land_meshes() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let maps_root = root.join("DATA").join("MAPS");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&maps_root, &mut files);
|
||||
files.sort();
|
||||
|
||||
let mut parsed = 0usize;
|
||||
for path in files {
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mesh = load_land_mesh(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||
assert!(
|
||||
!mesh.positions.is_empty() && !mesh.faces.is_empty(),
|
||||
"{} parsed but empty",
|
||||
path.display()
|
||||
);
|
||||
parsed += 1;
|
||||
}
|
||||
|
||||
assert!(parsed > 0, "no Land.msh files parsed");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "texm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
nres = { path = "../nres" }
|
||||
proptest = "1"
|
||||
@@ -1,15 +0,0 @@
|
||||
# texm
|
||||
|
||||
Парсер формата текстур `Texm`.
|
||||
|
||||
Покрывает:
|
||||
|
||||
- header (`width/height/mipCount/flags/format`);
|
||||
- core size расчёт;
|
||||
- optional `Page` chunk;
|
||||
- строгую валидацию layout.
|
||||
|
||||
Тесты:
|
||||
|
||||
- прогон по реальным `Texm` из `testdata`;
|
||||
- синтетические edge-cases (indexed + page, minimal rgba).
|
||||
@@ -1,86 +0,0 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
HeaderTooSmall {
|
||||
size: usize,
|
||||
},
|
||||
InvalidMagic {
|
||||
got: u32,
|
||||
},
|
||||
InvalidDimensions {
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
InvalidMipCount {
|
||||
mip_count: u32,
|
||||
},
|
||||
UnknownFormat {
|
||||
format: u32,
|
||||
},
|
||||
IntegerOverflow,
|
||||
CoreDataOutOfBounds {
|
||||
expected_end: usize,
|
||||
actual_size: usize,
|
||||
},
|
||||
MipIndexOutOfRange {
|
||||
requested: usize,
|
||||
mip_count: usize,
|
||||
},
|
||||
MipDataOutOfBounds {
|
||||
offset: usize,
|
||||
size: usize,
|
||||
payload_size: usize,
|
||||
},
|
||||
InvalidPageMagic,
|
||||
InvalidPageSize {
|
||||
expected: usize,
|
||||
actual: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::HeaderTooSmall { size } => {
|
||||
write!(f, "Texm payload too small for header: {size}")
|
||||
}
|
||||
Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"),
|
||||
Self::InvalidDimensions { width, height } => {
|
||||
write!(f, "invalid Texm dimensions: {width}x{height}")
|
||||
}
|
||||
Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"),
|
||||
Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"),
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Self::CoreDataOutOfBounds {
|
||||
expected_end,
|
||||
actual_size,
|
||||
} => write!(
|
||||
f,
|
||||
"Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
|
||||
),
|
||||
Self::MipIndexOutOfRange {
|
||||
requested,
|
||||
mip_count,
|
||||
} => write!(
|
||||
f,
|
||||
"Texm mip index out of range: requested={requested}, mip_count={mip_count}"
|
||||
),
|
||||
Self::MipDataOutOfBounds {
|
||||
offset,
|
||||
size,
|
||||
payload_size,
|
||||
} => write!(
|
||||
f,
|
||||
"Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}"
|
||||
),
|
||||
Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
|
||||
Self::InvalidPageSize { expected, actual } => {
|
||||
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
@@ -1,417 +0,0 @@
|
||||
pub mod error;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
pub const TEXM_MAGIC: u32 = 0x6D78_6554;
|
||||
pub const PAGE_MAGIC: u32 = 0x6567_6150;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PixelFormat {
|
||||
Indexed8,
|
||||
Rgb565,
|
||||
Rgb556,
|
||||
Argb4444,
|
||||
LuminanceAlpha88,
|
||||
Rgb888,
|
||||
Argb8888,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
pub fn from_raw(raw: u32) -> Option<Self> {
|
||||
match raw {
|
||||
0 => Some(Self::Indexed8),
|
||||
565 => Some(Self::Rgb565),
|
||||
556 => Some(Self::Rgb556),
|
||||
4444 => Some(Self::Argb4444),
|
||||
88 => Some(Self::LuminanceAlpha88),
|
||||
888 => Some(Self::Rgb888),
|
||||
8888 => Some(Self::Argb8888),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
Self::Indexed8 => 1,
|
||||
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
|
||||
// Parkan stores format 888 as 32-bit RGBX in texture payloads.
|
||||
Self::Rgb888 | Self::Argb8888 => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Header {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub mip_count: u32,
|
||||
pub flags4: u32,
|
||||
pub flags5: u32,
|
||||
pub unk6: u32,
|
||||
pub format_raw: u32,
|
||||
pub format: PixelFormat,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MipLevel {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PageRect {
|
||||
pub x: i16,
|
||||
pub w: i16,
|
||||
pub y: i16,
|
||||
pub h: i16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Texture {
|
||||
pub header: Header,
|
||||
pub palette: Option<[u8; 1024]>,
|
||||
pub mip_levels: Vec<MipLevel>,
|
||||
pub page_rects: Vec<PageRect>,
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
pub fn core_size(&self) -> usize {
|
||||
let mut size = 32usize;
|
||||
if self.palette.is_some() {
|
||||
size += 1024;
|
||||
}
|
||||
for level in &self.mip_levels {
|
||||
size += level.size;
|
||||
}
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DecodedMip {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba8: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
||||
if payload.len() < 32 {
|
||||
return Err(Error::HeaderTooSmall {
|
||||
size: payload.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let magic = read_u32(payload, 0)?;
|
||||
if magic != TEXM_MAGIC {
|
||||
return Err(Error::InvalidMagic { got: magic });
|
||||
}
|
||||
|
||||
let width = read_u32(payload, 4)?;
|
||||
let height = read_u32(payload, 8)?;
|
||||
let mip_count = read_u32(payload, 12)?;
|
||||
let flags4 = read_u32(payload, 16)?;
|
||||
let flags5 = read_u32(payload, 20)?;
|
||||
let unk6 = read_u32(payload, 24)?;
|
||||
let format_raw = read_u32(payload, 28)?;
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Err(Error::InvalidDimensions { width, height });
|
||||
}
|
||||
if mip_count == 0 {
|
||||
return Err(Error::InvalidMipCount { mip_count });
|
||||
}
|
||||
|
||||
let format =
|
||||
PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?;
|
||||
let bytes_per_pixel = format.bytes_per_pixel();
|
||||
|
||||
let mut offset = 32usize;
|
||||
let palette = if format == PixelFormat::Indexed8 {
|
||||
let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?;
|
||||
if end > payload.len() {
|
||||
return Err(Error::CoreDataOutOfBounds {
|
||||
expected_end: end,
|
||||
actual_size: payload.len(),
|
||||
});
|
||||
}
|
||||
let mut pal = [0u8; 1024];
|
||||
pal.copy_from_slice(&payload[offset..end]);
|
||||
offset = end;
|
||||
Some(pal)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut mip_levels =
|
||||
Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?);
|
||||
let mut w = width;
|
||||
let mut h = height;
|
||||
for _ in 0..mip_count {
|
||||
let pixel_count_u64 = u64::from(w)
|
||||
.checked_mul(u64::from(h))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let level_size_u64 = pixel_count_u64
|
||||
.checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?;
|
||||
let level_offset = offset;
|
||||
offset = offset
|
||||
.checked_add(level_size)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
if offset > payload.len() {
|
||||
return Err(Error::CoreDataOutOfBounds {
|
||||
expected_end: offset,
|
||||
actual_size: payload.len(),
|
||||
});
|
||||
}
|
||||
mip_levels.push(MipLevel {
|
||||
width: w,
|
||||
height: h,
|
||||
offset: level_offset,
|
||||
size: level_size,
|
||||
});
|
||||
w = (w >> 1).max(1);
|
||||
h = (h >> 1).max(1);
|
||||
}
|
||||
|
||||
let page_rects = parse_page_tail(payload, offset)?;
|
||||
|
||||
Ok(Texture {
|
||||
header: Header {
|
||||
width,
|
||||
height,
|
||||
mip_count,
|
||||
flags4,
|
||||
flags5,
|
||||
unk6,
|
||||
format_raw,
|
||||
format,
|
||||
},
|
||||
palette,
|
||||
mip_levels,
|
||||
page_rects,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
|
||||
let Some(level) = texture.mip_levels.get(mip_index).copied() else {
|
||||
return Err(Error::MipIndexOutOfRange {
|
||||
requested: mip_index,
|
||||
mip_count: texture.mip_levels.len(),
|
||||
});
|
||||
};
|
||||
|
||||
let end = level
|
||||
.offset
|
||||
.checked_add(level.size)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let Some(level_data) = payload.get(level.offset..end) else {
|
||||
return Err(Error::MipDataOutOfBounds {
|
||||
offset: level.offset,
|
||||
size: level.size,
|
||||
payload_size: payload.len(),
|
||||
});
|
||||
};
|
||||
|
||||
let pixel_count = usize::try_from(level.width)
|
||||
.ok()
|
||||
.and_then(|w| {
|
||||
usize::try_from(level.height)
|
||||
.ok()
|
||||
.map(|h| w.saturating_mul(h))
|
||||
})
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
|
||||
|
||||
match texture.header.format {
|
||||
PixelFormat::Indexed8 => {
|
||||
let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?;
|
||||
for (i, &index) in level_data.iter().enumerate() {
|
||||
if i >= pixel_count {
|
||||
break;
|
||||
}
|
||||
let poff = usize::from(index).saturating_mul(4);
|
||||
// Keep this form to accept the last palette item (index 255).
|
||||
if poff + 4 > palette.len() {
|
||||
continue;
|
||||
}
|
||||
let out = i.saturating_mul(4);
|
||||
rgba[out] = palette[poff];
|
||||
rgba[out + 1] = palette[poff + 1];
|
||||
rgba[out + 2] = palette[poff + 2];
|
||||
rgba[out + 3] = palette[poff + 3];
|
||||
}
|
||||
}
|
||||
PixelFormat::Rgb565 => {
|
||||
decode_words(level_data, pixel_count, &mut rgba, decode_rgb565);
|
||||
}
|
||||
PixelFormat::Rgb556 => {
|
||||
decode_words(level_data, pixel_count, &mut rgba, decode_rgb556);
|
||||
}
|
||||
PixelFormat::Argb4444 => {
|
||||
decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
|
||||
}
|
||||
PixelFormat::LuminanceAlpha88 => {
|
||||
decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
|
||||
}
|
||||
PixelFormat::Rgb888 => {
|
||||
decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
|
||||
}
|
||||
PixelFormat::Argb8888 => {
|
||||
decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DecodedMip {
|
||||
width: level.width,
|
||||
height: level.height,
|
||||
rgba8: rgba,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
|
||||
if core_end == payload.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if payload.len().saturating_sub(core_end) < 8 {
|
||||
return Err(Error::InvalidPageSize {
|
||||
expected: 8,
|
||||
actual: payload.len().saturating_sub(core_end),
|
||||
});
|
||||
}
|
||||
let magic = read_u32(payload, core_end)?;
|
||||
if magic != PAGE_MAGIC {
|
||||
return Err(Error::InvalidPageMagic);
|
||||
}
|
||||
let rect_count = read_u32(payload, core_end + 4)?;
|
||||
let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?;
|
||||
let expected_size = 8usize
|
||||
.checked_add(
|
||||
rect_count_usize
|
||||
.checked_mul(8)
|
||||
.ok_or(Error::IntegerOverflow)?,
|
||||
)
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
let actual = payload.len().saturating_sub(core_end);
|
||||
if expected_size != actual {
|
||||
return Err(Error::InvalidPageSize {
|
||||
expected: expected_size,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
|
||||
let mut rects = Vec::with_capacity(rect_count_usize);
|
||||
for i in 0..rect_count_usize {
|
||||
let off = core_end
|
||||
.checked_add(8)
|
||||
.and_then(|v| v.checked_add(i * 8))
|
||||
.ok_or(Error::IntegerOverflow)?;
|
||||
rects.push(PageRect {
|
||||
x: read_i16(payload, off)?,
|
||||
w: read_i16(payload, off + 2)?,
|
||||
y: read_i16(payload, off + 4)?,
|
||||
h: read_i16(payload, off + 6)?,
|
||||
});
|
||||
}
|
||||
Ok(rects)
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
|
||||
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||
Ok(i16::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
|
||||
for i in 0..pixel_count {
|
||||
let off = i.saturating_mul(2);
|
||||
let Some(bytes) = data.get(off..off + 2) else {
|
||||
break;
|
||||
};
|
||||
let word = u16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
let px = decode(word);
|
||||
let out = i.saturating_mul(4);
|
||||
rgba[out..out + 4].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
|
||||
for i in 0..pixel_count {
|
||||
let off = i.saturating_mul(4);
|
||||
let Some(bytes) = data.get(off..off + 4) else {
|
||||
break;
|
||||
};
|
||||
let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
let px = decode(dword);
|
||||
let out = i.saturating_mul(4);
|
||||
rgba[out..out + 4].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
|
||||
fn expand5(v: u16) -> u8 {
|
||||
((u32::from(v) * 255 + 15) / 31) as u8
|
||||
}
|
||||
|
||||
fn expand6(v: u16) -> u8 {
|
||||
((u32::from(v) * 255 + 31) / 63) as u8
|
||||
}
|
||||
|
||||
fn expand4(v: u16) -> u8 {
|
||||
(u32::from(v) * 17) as u8
|
||||
}
|
||||
|
||||
fn decode_rgb565(word: u16) -> [u8; 4] {
|
||||
let r = expand5((word >> 11) & 0x1F);
|
||||
let g = expand6((word >> 5) & 0x3F);
|
||||
let b = expand5(word & 0x1F);
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
fn decode_rgb556(word: u16) -> [u8; 4] {
|
||||
let r = expand5((word >> 11) & 0x1F);
|
||||
let g = expand5((word >> 6) & 0x1F);
|
||||
let b = expand6(word & 0x3F);
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
fn decode_argb4444(word: u16) -> [u8; 4] {
|
||||
let a = expand4((word >> 12) & 0x0F);
|
||||
let r = expand4((word >> 8) & 0x0F);
|
||||
let g = expand4((word >> 4) & 0x0F);
|
||||
let b = expand4(word & 0x0F);
|
||||
[r, g, b, a]
|
||||
}
|
||||
|
||||
fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
|
||||
let l = ((word >> 8) & 0xFF) as u8;
|
||||
let a = (word & 0xFF) as u8;
|
||||
[l, l, l, a]
|
||||
}
|
||||
|
||||
fn decode_rgb888x(dword: u32) -> [u8; 4] {
|
||||
let r = (dword & 0xFF) as u8;
|
||||
let g = ((dword >> 8) & 0xFF) as u8;
|
||||
let b = ((dword >> 16) & 0xFF) as u8;
|
||||
[r, g, b, 255]
|
||||
}
|
||||
|
||||
fn decode_argb8888(dword: u32) -> [u8; 4] {
|
||||
let a = (dword & 0xFF) as u8;
|
||||
let r = ((dword >> 8) & 0xFF) as u8;
|
||||
let g = ((dword >> 16) & 0xFF) as u8;
|
||||
let b = ((dword >> 24) & 0xFF) as u8;
|
||||
[r, g, b, a]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,330 +0,0 @@
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use nres::Archive;
|
||||
use proptest::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn nres_test_files() -> Vec<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&root, &mut files);
|
||||
files.sort();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
fs::read(path)
|
||||
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_texm_payload(
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_raw: u32,
|
||||
flags5: u32,
|
||||
palette: Option<[u8; 1024]>,
|
||||
mip_levels: &[&[u8]],
|
||||
) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
||||
payload.extend_from_slice(&width.to_le_bytes());
|
||||
payload.extend_from_slice(&height.to_le_bytes());
|
||||
payload.extend_from_slice(
|
||||
&u32::try_from(mip_levels.len())
|
||||
.expect("mip level count overflow in test")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
||||
payload.extend_from_slice(&flags5.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
||||
payload.extend_from_slice(&format_raw.to_le_bytes());
|
||||
if let Some(palette) = palette {
|
||||
payload.extend_from_slice(&palette);
|
||||
}
|
||||
for level in mip_levels {
|
||||
payload.extend_from_slice(level);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_parse_all_game_textures() {
|
||||
let archives = nres_test_files();
|
||||
if archives.is_empty() {
|
||||
eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut texm_total = 0usize;
|
||||
let mut texm_with_page = 0usize;
|
||||
for archive_path in archives {
|
||||
let archive = Archive::open_path(&archive_path)
|
||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||
|
||||
for entry in archive.entries() {
|
||||
if entry.meta.kind != TEXM_MAGIC {
|
||||
continue;
|
||||
}
|
||||
texm_total += 1;
|
||||
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read Texm entry '{}' in {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to parse Texm '{}' in {}: {err}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
)
|
||||
});
|
||||
if !texture.page_rects.is_empty() {
|
||||
texm_with_page += 1;
|
||||
}
|
||||
|
||||
assert!(
|
||||
texture.core_size() <= payload.as_slice().len(),
|
||||
"core size must be within payload for '{}' in {}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
);
|
||||
assert_eq!(
|
||||
usize::try_from(texture.header.mip_count).ok(),
|
||||
Some(texture.mip_levels.len()),
|
||||
"mip count mismatch for '{}' in {}",
|
||||
entry.meta.name,
|
||||
archive_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(texm_total > 0, "no Texm textures found");
|
||||
assert!(
|
||||
texm_with_page > 0,
|
||||
"expected at least one Texm texture with Page chunk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_parse_minimal_argb8888_no_page() {
|
||||
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||
|
||||
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||
assert_eq!(parsed.header.width, 1);
|
||||
assert_eq!(parsed.header.height, 1);
|
||||
assert_eq!(parsed.mip_levels.len(), 1);
|
||||
assert!(parsed.page_rects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_minimal_argb8888_no_page() {
|
||||
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
|
||||
assert_eq!(decoded.width, 1);
|
||||
assert_eq!(decoded.height, 1);
|
||||
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_rgb565() {
|
||||
let word = 0xFFE0u16; // r=31 g=63 b=0
|
||||
let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
|
||||
assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_rgb556() {
|
||||
let word = 0xF800u16; // r=31 g=0 b=0
|
||||
let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
|
||||
assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_argb4444() {
|
||||
let word = 0xF12Eu16; // a=F r=1 g=2 b=E
|
||||
let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
|
||||
assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_luminance_alpha88() {
|
||||
let word = 0x7F40u16; // luminance=0x7F alpha=0x40
|
||||
let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
|
||||
assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_rgb888x() {
|
||||
let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
|
||||
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_parse_indexed_with_page_chunk() {
|
||||
let mut palette = [0u8; 1024];
|
||||
palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
|
||||
let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
|
||||
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
|
||||
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
|
||||
payload.extend_from_slice(&0i16.to_le_bytes()); // x
|
||||
payload.extend_from_slice(&2i16.to_le_bytes()); // w
|
||||
payload.extend_from_slice(&0i16.to_le_bytes()); // y
|
||||
payload.extend_from_slice(&2i16.to_le_bytes()); // h
|
||||
|
||||
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
||||
assert!(parsed.palette.is_some());
|
||||
assert_eq!(parsed.page_rects.len(), 1);
|
||||
assert_eq!(
|
||||
parsed.page_rects[0],
|
||||
PageRect {
|
||||
x: 0,
|
||||
w: 2,
|
||||
y: 0,
|
||||
h: 2
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_decode_indexed_with_palette_last_entry() {
|
||||
let mut palette = [0u8; 1024];
|
||||
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
|
||||
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
|
||||
palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
|
||||
let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
|
||||
|
||||
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
|
||||
assert_eq!(decoded.width, 3);
|
||||
assert_eq!(decoded.height, 1);
|
||||
assert_eq!(
|
||||
decoded.rgba8,
|
||||
vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_parse_multi_mip_offsets() {
|
||||
let mip0 = [0x10u8; 32]; // 4*2*4
|
||||
let mip1 = [0x20u8; 8]; // 2*1*4
|
||||
let mip2 = [0x30u8; 4]; // 1*1*4
|
||||
let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
|
||||
|
||||
let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
|
||||
assert_eq!(parsed.header.mip_count, 3);
|
||||
assert_eq!(parsed.mip_levels.len(), 3);
|
||||
assert_eq!(
|
||||
parsed.mip_levels,
|
||||
vec![
|
||||
MipLevel {
|
||||
width: 4,
|
||||
height: 2,
|
||||
offset: 32,
|
||||
size: 32
|
||||
},
|
||||
MipLevel {
|
||||
width: 2,
|
||||
height: 1,
|
||||
offset: 64,
|
||||
size: 8
|
||||
},
|
||||
MipLevel {
|
||||
width: 1,
|
||||
height: 1,
|
||||
offset: 72,
|
||||
size: 4
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_preserves_flags5_for_mip_skip_metadata() {
|
||||
let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse texm");
|
||||
assert_eq!(parsed.header.flags5, 0x0000_00A5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_errors_for_invalid_header_values() {
|
||||
let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||
bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
parse_texm(&bad_magic),
|
||||
Err(Error::InvalidMagic { .. })
|
||||
));
|
||||
|
||||
let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
|
||||
assert!(matches!(
|
||||
parse_texm(&zero_dims),
|
||||
Err(Error::InvalidDimensions { .. })
|
||||
));
|
||||
|
||||
let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||
bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
parse_texm(&bad_mips),
|
||||
Err(Error::InvalidMipCount { .. })
|
||||
));
|
||||
|
||||
let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
|
||||
assert!(matches!(
|
||||
parse_texm(&bad_format),
|
||||
Err(Error::UnknownFormat { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texm_errors_for_page_chunk_and_mip_bounds() {
|
||||
let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||
bad_page.extend_from_slice(b"X");
|
||||
assert!(matches!(
|
||||
parse_texm(&bad_page),
|
||||
Err(Error::InvalidPageSize { .. })
|
||||
));
|
||||
|
||||
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||
let parsed = parse_texm(&payload).expect("failed to parse valid texm");
|
||||
assert!(matches!(
|
||||
decode_mip_rgba8(&parsed, &payload, 7),
|
||||
Err(Error::MipIndexOutOfRange { .. })
|
||||
));
|
||||
|
||||
let truncated = &payload[..payload.len() - 1];
|
||||
assert!(matches!(
|
||||
decode_mip_rgba8(&parsed, truncated, 0),
|
||||
Err(Error::MipDataOutOfBounds { .. })
|
||||
));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||
|
||||
#[test]
|
||||
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||
if let Ok(texture) = parse_texm(&payload) {
|
||||
for mip_index in 0..texture.mip_levels.len() {
|
||||
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "tma"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
@@ -1,485 +0,0 @@
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
|
||||
const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
|
||||
const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
FooterNotFound,
|
||||
FooterCorrupt(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
|
||||
Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MissionFile {
|
||||
pub footer: MissionFooter,
|
||||
pub objects: Vec<MissionObject>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MissionFooter {
|
||||
pub map_path: String,
|
||||
pub title: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MissionObject {
|
||||
pub offset: usize,
|
||||
pub group_id: u32,
|
||||
pub flags: u32,
|
||||
pub resource_name: String,
|
||||
pub logical_id: i32,
|
||||
pub clan_id: i32,
|
||||
pub position: [f32; 3],
|
||||
pub orientation: [f32; 3],
|
||||
pub scale: [f32; 3],
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
parse_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
|
||||
let footer = parse_footer(bytes)?;
|
||||
let objects = parse_objects(bytes);
|
||||
Ok(MissionFile { footer, objects })
|
||||
}
|
||||
|
||||
fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
|
||||
let map_positions = find_all_map_path_positions(bytes);
|
||||
if map_positions.is_empty() {
|
||||
return Err(Error::FooterNotFound);
|
||||
}
|
||||
|
||||
for map_start in map_positions.into_iter().rev() {
|
||||
if map_start < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let map_end = scan_path_end(bytes, map_start);
|
||||
if map_end <= map_start {
|
||||
continue;
|
||||
}
|
||||
let map_len = map_end - map_start;
|
||||
let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
|
||||
continue;
|
||||
};
|
||||
if declared_map_len != map_len {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(zero_pad) = read_u32(bytes, map_end) else {
|
||||
continue;
|
||||
};
|
||||
if zero_pad != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title_len_off = map_end + 4;
|
||||
let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
|
||||
continue;
|
||||
};
|
||||
if title_len == 0 || title_len > 256 {
|
||||
continue;
|
||||
}
|
||||
let title_start = title_len_off + 4;
|
||||
let Some(title_end) = title_start.checked_add(title_len) else {
|
||||
continue;
|
||||
};
|
||||
if title_end > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||
continue;
|
||||
}
|
||||
let title = decode_title(&bytes[title_start..title_end]);
|
||||
let version = parse_footer_version(bytes, title_end)?;
|
||||
|
||||
return Ok(MissionFooter {
|
||||
map_path,
|
||||
title,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for multiplayer/legacy variants where the footer tail differs,
|
||||
// but map path is still present in clear text near EOF.
|
||||
let Some(map_start) = bytes
|
||||
.windows(MAP_PATH_TOKEN.len())
|
||||
.rposition(|window| window == MAP_PATH_TOKEN)
|
||||
else {
|
||||
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||
};
|
||||
let map_end = scan_path_end(bytes, map_start);
|
||||
if map_end <= map_start {
|
||||
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||
}
|
||||
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||
}
|
||||
|
||||
let mut title = String::new();
|
||||
if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
|
||||
let title_start = map_end + 12;
|
||||
let title_end = title_start.saturating_add(title_len);
|
||||
if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
|
||||
let raw = &bytes[title_start..title_end];
|
||||
if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
|
||||
title = decode_title(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let version = if let Some(magic_off) = bytes
|
||||
.windows(FOOTER_MAGIC.len())
|
||||
.rposition(|window| window == FOOTER_MAGIC)
|
||||
{
|
||||
read_u32(bytes, magic_off + 4).unwrap_or(1)
|
||||
} else {
|
||||
read_u32(bytes, map_end).unwrap_or(1)
|
||||
};
|
||||
|
||||
Ok(MissionFooter {
|
||||
map_path,
|
||||
title,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
|
||||
if after_title_off + 8 <= bytes.len()
|
||||
&& &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
|
||||
{
|
||||
let version = read_u32(bytes, after_title_off + 4)
|
||||
.ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
|
||||
return Ok(version);
|
||||
}
|
||||
|
||||
let version = read_u32(bytes, after_title_off)
|
||||
.ok_or(Error::FooterCorrupt("missing version after title"))?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
|
||||
bytes
|
||||
.windows(MAP_PATH_TOKEN.len())
|
||||
.enumerate()
|
||||
.filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scan_path_end(bytes: &[u8], start: usize) -> usize {
|
||||
let mut off = start;
|
||||
while off < bytes.len() && is_path_byte(bytes[off]) {
|
||||
off += 1;
|
||||
}
|
||||
off
|
||||
}
|
||||
|
||||
fn is_path_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
|
||||
}
|
||||
|
||||
fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
|
||||
let mut objects = Vec::new();
|
||||
let min_record_tail = 48usize;
|
||||
|
||||
for offset in 0..bytes.len().saturating_sub(16) {
|
||||
let Some(flags) = read_u32(bytes, offset + 4) else {
|
||||
continue;
|
||||
};
|
||||
if flags != OBJECT_RECORD_FLAGS {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
|
||||
continue;
|
||||
};
|
||||
if !(3..=260).contains(&name_len) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = offset + 12;
|
||||
let Some(name_end) = name_start.checked_add(name_len) else {
|
||||
continue;
|
||||
};
|
||||
if name_end + min_record_tail > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_raw = &bytes[name_start..name_end];
|
||||
if !is_object_name_bytes(name_raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resource_name = decode_cp1251(name_raw);
|
||||
if !looks_like_object_name(&resource_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(group_id) = read_u32(bytes, offset) else {
|
||||
continue;
|
||||
};
|
||||
let Some(logical_id) = read_i32(bytes, name_end) else {
|
||||
continue;
|
||||
};
|
||||
let Some(clan_id) = read_i32(bytes, name_end + 4) else {
|
||||
continue;
|
||||
};
|
||||
let Some(position) = read_vec3(bytes, name_end + 8) else {
|
||||
continue;
|
||||
};
|
||||
let Some(orientation) = read_vec3(bytes, name_end + 20) else {
|
||||
continue;
|
||||
};
|
||||
let Some(scale) = read_vec3(bytes, name_end + 32) else {
|
||||
continue;
|
||||
};
|
||||
if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let alias = parse_alias(bytes, name_end + 44);
|
||||
|
||||
objects.push(MissionObject {
|
||||
offset,
|
||||
group_id,
|
||||
flags,
|
||||
resource_name,
|
||||
logical_id,
|
||||
clan_id,
|
||||
position,
|
||||
orientation,
|
||||
scale,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
objects.sort_by_key(|obj| obj.offset);
|
||||
objects.dedup_by_key(|obj| obj.offset);
|
||||
objects
|
||||
}
|
||||
|
||||
fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
|
||||
let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
|
||||
return String::new();
|
||||
};
|
||||
if alias_len == 0 || alias_len > 96 {
|
||||
return String::new();
|
||||
}
|
||||
let alias_start = alias_len_off + 4;
|
||||
let Some(alias_end) = alias_start.checked_add(alias_len) else {
|
||||
return String::new();
|
||||
};
|
||||
if alias_end > bytes.len() {
|
||||
return String::new();
|
||||
}
|
||||
let alias_raw = &bytes[alias_start..alias_end];
|
||||
if !alias_raw
|
||||
.iter()
|
||||
.all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
|
||||
{
|
||||
return String::new();
|
||||
}
|
||||
decode_cp1251(alias_raw)
|
||||
}
|
||||
|
||||
fn looks_like_object_name(name: &str) -> bool {
|
||||
if name.ends_with(".dat") {
|
||||
return true;
|
||||
}
|
||||
name.contains('_')
|
||||
}
|
||||
|
||||
fn is_object_name_bytes(bytes: &[u8]) -> bool {
|
||||
bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
|
||||
}
|
||||
|
||||
fn all_finite(v: &[f32; 3]) -> bool {
|
||||
v.iter().all(|c| c.is_finite())
|
||||
}
|
||||
|
||||
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
|
||||
fn decode_title(bytes: &[u8]) -> String {
|
||||
let end = bytes
|
||||
.iter()
|
||||
.rposition(|b| *b != 0 && *b != 0xCD)
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
decode_cp1251(&bytes[..end]).trim().to_string()
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
let end = offset.checked_add(4)?;
|
||||
let chunk = bytes.get(offset..end)?;
|
||||
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
|
||||
read_u32(bytes, offset).map(|v| v as i32)
|
||||
}
|
||||
|
||||
fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
|
||||
let end = offset.checked_add(4)?;
|
||||
let chunk = bytes.get(offset..end)?;
|
||||
Some(f32::from_le_bytes(chunk.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
|
||||
Some([
|
||||
read_f32(bytes, offset)?,
|
||||
read_f32(bytes, offset + 4)?,
|
||||
read_f32(bytes, offset + 8)?,
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn game_root() -> Option<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("Parkan - Iron Strategy");
|
||||
root.is_dir().then_some(root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_known_mission_footer_and_objects() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root is missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = root
|
||||
.join("MISSIONS")
|
||||
.join("CAMPAIGN")
|
||||
.join("CAMPAIGN.00")
|
||||
.join("Mission.01")
|
||||
.join("data.tma");
|
||||
if !path.is_file() {
|
||||
eprintln!("skipping: sample mission is missing ({})", path.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let mission = parse_path(&path).expect("parse mission failed");
|
||||
assert_eq!(mission.footer.version, 1);
|
||||
assert!(
|
||||
mission
|
||||
.footer
|
||||
.map_path
|
||||
.eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
|
||||
"unexpected map path: {}",
|
||||
mission.footer.map_path
|
||||
);
|
||||
assert!(mission.objects.len() >= 20);
|
||||
assert!(mission
|
||||
.objects
|
||||
.iter()
|
||||
.any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
|
||||
assert!(mission.objects.iter().any(|obj| {
|
||||
obj.resource_name
|
||||
.eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_retail_missions() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root is missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let mission_root = root.join("MISSIONS");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&mission_root, &mut files);
|
||||
files.sort();
|
||||
|
||||
let mut mission_count = 0usize;
|
||||
for path in files {
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mission_count += 1;
|
||||
let mission = parse_path(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||
assert!(
|
||||
mission
|
||||
.footer
|
||||
.map_path
|
||||
.to_ascii_uppercase()
|
||||
.contains("DATA\\MAPS\\"),
|
||||
"{}: invalid map path '{}'",
|
||||
path.display(),
|
||||
mission.footer.map_path
|
||||
);
|
||||
assert!(
|
||||
!mission.objects.is_empty(),
|
||||
"{}: mission has no parsed object records",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
mission
|
||||
.objects
|
||||
.iter()
|
||||
.all(|obj| obj.position.iter().all(|v| v.is_finite())),
|
||||
"{}: mission has non-finite position",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
assert!(mission_count > 0, "no data.tma files found");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "unitdat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
common = { path = "../common" }
|
||||
@@ -1,180 +0,0 @@
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const MIN_SIZE: usize = 0x48;
|
||||
const MAGIC: u32 = 0x0000_F0F1;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
TooSmall { got: usize },
|
||||
InvalidMagic { got: u32 },
|
||||
MissingArchiveName,
|
||||
MissingModelKey,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "{err}"),
|
||||
Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
|
||||
Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
|
||||
Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
|
||||
Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UnitDat {
|
||||
pub magic: u32,
|
||||
pub flags: u32,
|
||||
pub archive_name: String,
|
||||
pub model_key: String,
|
||||
}
|
||||
|
||||
pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
|
||||
let bytes = fs::read(path.as_ref())?;
|
||||
parse_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
|
||||
if bytes.len() < MIN_SIZE {
|
||||
return Err(Error::TooSmall { got: bytes.len() });
|
||||
}
|
||||
|
||||
let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||
if magic != MAGIC {
|
||||
return Err(Error::InvalidMagic { got: magic });
|
||||
}
|
||||
|
||||
let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||
let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
|
||||
if archive_name.is_empty() {
|
||||
return Err(Error::MissingArchiveName);
|
||||
}
|
||||
|
||||
let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
|
||||
if model_key.is_empty() {
|
||||
return Err(Error::MissingModelKey);
|
||||
}
|
||||
|
||||
Ok(UnitDat {
|
||||
magic,
|
||||
flags,
|
||||
archive_name,
|
||||
model_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
let end = offset.checked_add(4)?;
|
||||
let chunk = bytes.get(offset..end)?;
|
||||
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn decode_c_string_fixed(bytes: &[u8]) -> String {
|
||||
let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::collect_files_recursive;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn game_root() -> Option<PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("testdata")
|
||||
.join("Parkan - Iron Strategy");
|
||||
root.is_dir().then_some(root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_known_dat_files() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let samples = [
|
||||
root.join("UNITS/UNITS/HERO/tut1_p.dat"),
|
||||
root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
|
||||
root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
|
||||
];
|
||||
|
||||
for path in samples {
|
||||
if !path.is_file() {
|
||||
eprintln!("skipping missing sample {}", path.display());
|
||||
continue;
|
||||
}
|
||||
let dat = parse_path(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||
assert_eq!(dat.magic, MAGIC);
|
||||
assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
|
||||
assert!(dat.model_key.contains('_'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_dat_corpus() {
|
||||
let Some(root) = game_root() else {
|
||||
eprintln!("skipping: game root missing");
|
||||
return;
|
||||
};
|
||||
|
||||
let units_root = root.join("UNITS");
|
||||
let mut files = Vec::new();
|
||||
collect_files_recursive(&units_root, &mut files);
|
||||
files.sort();
|
||||
|
||||
let mut parsed = 0usize;
|
||||
for path in files {
|
||||
if !path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dat = parse_path(&path)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||
assert!(
|
||||
!dat.archive_name.is_empty(),
|
||||
"{} empty archive",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
!dat.model_key.is_empty(),
|
||||
"{} empty model key",
|
||||
path.display()
|
||||
);
|
||||
parsed += 1;
|
||||
}
|
||||
|
||||
assert!(parsed > 0, "no .dat files parsed");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user