use std::collections::HashMap;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::pack_distribute::{PackBuilder, PackVerifier};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextureFormat {
Png,
Jpeg,
Exr,
}
impl TextureFormat {
fn extension(&self) -> &'static str {
match self {
TextureFormat::Png => "png",
TextureFormat::Jpeg => "jpg",
TextureFormat::Exr => "exr",
}
}
#[allow(dead_code)]
fn mime_type(&self) -> &'static str {
match self {
TextureFormat::Png => "image/png",
TextureFormat::Jpeg => "image/jpeg",
TextureFormat::Exr => "image/x-exr",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextureAsset {
pub name: String,
pub width: u32,
pub height: u32,
pub channels: u8,
pub data: Vec<u8>,
pub format: TextureFormat,
}
impl TextureAsset {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
bail!("texture name must not be empty");
}
if self.width == 0 || self.height == 0 {
bail!("texture dimensions must be non-zero");
}
if self.channels == 0 || self.channels > 4 {
bail!("texture channels must be 1–4, got {}", self.channels);
}
let expected = self.width as usize * self.height as usize * self.channels as usize;
if self.data.len() != expected {
bail!(
"texture '{}': expected {} bytes ({} x {} x {}), got {}",
self.name,
expected,
self.width,
self.height,
self.channels,
self.data.len()
);
}
Ok(())
}
fn pack_path(&self) -> String {
format!("textures/{}.{}", self.name, self.format.extension())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialDef {
pub name: String,
pub albedo_color: [f32; 4],
pub metallic: f32,
pub roughness: f32,
pub emissive: [f32; 3],
pub albedo_texture: Option<String>,
pub normal_texture: Option<String>,
}
impl MaterialDef {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
bail!("material name must not be empty");
}
if !(0.0..=1.0).contains(&self.metallic) {
bail!(
"material '{}': metallic {} is out of [0,1]",
self.name,
self.metallic
);
}
if !(0.0..=1.0).contains(&self.roughness) {
bail!(
"material '{}': roughness {} is out of [0,1]",
self.name,
self.roughness
);
}
Ok(())
}
fn pack_path(&self) -> String {
format!("materials/{}.json", self.name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MorphPreset {
pub name: String,
pub description: String,
pub params: HashMap<String, f64>,
pub tags: Vec<String>,
}
impl MorphPreset {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
bail!("morph preset name must not be empty");
}
Ok(())
}
fn pack_path(&self) -> String {
let slug: String = self
.name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
format!("presets/{}.json", slug)
}
}
#[derive(Debug, Clone)]
pub enum AssetPackEntry {
Target(TargetDelta),
Texture(TextureAsset),
Material(MaterialDef),
Preset(MorphPreset),
}
#[derive(Debug, Clone)]
pub struct TargetDelta {
pub name: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetPackMeta {
pub version: String,
pub author: String,
pub license: String,
pub description: String,
pub created_at: u64,
}
impl Default for AssetPackMeta {
fn default() -> Self {
Self {
version: "0.1.0".to_string(),
author: String::new(),
license: "Apache-2.0".to_string(),
description: String::new(),
created_at: 0,
}
}
}
pub struct AssetPackBuilder {
name: String,
meta: AssetPackMeta,
entries: Vec<AssetPackEntry>,
}
impl AssetPackBuilder {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
meta: AssetPackMeta::default(),
entries: Vec::new(),
}
}
pub fn set_meta(&mut self, meta: AssetPackMeta) -> &mut Self {
self.meta = meta;
self
}
pub fn set_author(&mut self, author: &str) -> &mut Self {
self.meta.author = author.to_string();
self
}
pub fn set_version(&mut self, version: &str) -> &mut Self {
self.meta.version = version.to_string();
self
}
pub fn set_license(&mut self, license: &str) -> &mut Self {
self.meta.license = license.to_string();
self
}
pub fn set_description(&mut self, desc: &str) -> &mut Self {
self.meta.description = desc.to_string();
self
}
pub fn add_target(&mut self, delta: TargetDelta) -> &mut Self {
self.entries.push(AssetPackEntry::Target(delta));
self
}
pub fn add_texture(&mut self, tex: TextureAsset) -> Result<&mut Self> {
tex.validate()?;
self.entries.push(AssetPackEntry::Texture(tex));
Ok(self)
}
pub fn add_material(&mut self, mat: MaterialDef) -> Result<&mut Self> {
mat.validate()?;
self.entries.push(AssetPackEntry::Material(mat));
Ok(self)
}
pub fn add_preset(&mut self, preset: MorphPreset) -> Result<&mut Self> {
preset.validate()?;
self.entries.push(AssetPackEntry::Preset(preset));
Ok(self)
}
fn add_preset_unchecked(&mut self, preset: MorphPreset) -> &mut Self {
self.entries.push(AssetPackEntry::Preset(preset));
self
}
fn add_material_unchecked(&mut self, mat: MaterialDef) -> &mut Self {
self.entries.push(AssetPackEntry::Material(mat));
self
}
pub fn build(&self) -> Result<Vec<u8>> {
let mut pack = PackBuilder::new(&self.name, &self.meta.version, &self.meta.author);
pack.set_description(&self.meta.description);
pack.set_license(&self.meta.license);
pack.set_created_at(self.meta.created_at);
for entry in &self.entries {
match entry {
AssetPackEntry::Target(delta) => {
pack.add_target_file(&delta.name, "targets", &delta.data)
.with_context(|| format!("failed to add target '{}'", delta.name))?;
}
AssetPackEntry::Texture(tex) => {
let json = serde_json::to_vec(tex)
.with_context(|| format!("failed to serialize texture '{}'", tex.name))?;
pack.add_target_file(&tex.pack_path(), "textures", &json)
.with_context(|| format!("failed to add texture '{}'", tex.name))?;
}
AssetPackEntry::Material(mat) => {
let json = serde_json::to_vec(mat)
.with_context(|| format!("failed to serialize material '{}'", mat.name))?;
pack.add_target_file(&mat.pack_path(), "materials", &json)
.with_context(|| format!("failed to add material '{}'", mat.name))?;
}
AssetPackEntry::Preset(preset) => {
let json = serde_json::to_vec(preset)
.with_context(|| format!("failed to serialize preset '{}'", preset.name))?;
pack.add_target_file(&preset.pack_path(), "presets", &json)
.with_context(|| format!("failed to add preset '{}'", preset.name))?;
}
}
}
pack.build()
}
}
#[derive(Debug, Clone)]
pub struct AssetPackIndex {
pub name: String,
pub version: String,
pub author: String,
pub license: String,
pub description: String,
pub textures: Vec<TextureAsset>,
pub materials: Vec<MaterialDef>,
pub presets: Vec<MorphPreset>,
pub target_names: Vec<String>,
pub total_bytes: usize,
}
pub fn load_pack_from_bytes(bytes: &[u8]) -> Result<AssetPackIndex> {
let manifest =
PackVerifier::verify_integrity(bytes).with_context(|| "OXP integrity check failed")?;
let mut index = AssetPackIndex {
name: manifest.name.clone(),
version: manifest.version.clone(),
author: manifest.author.clone(),
license: manifest.license.clone(),
description: manifest.description.clone(),
textures: Vec::new(),
materials: Vec::new(),
presets: Vec::new(),
target_names: Vec::new(),
total_bytes: bytes.len(),
};
for entry in &manifest.targets {
match entry.category.as_str() {
"textures" => {
let data = PackVerifier::extract_file(bytes, &entry.file_path)
.with_context(|| format!("extracting texture '{}'", entry.file_path))?;
let tex: TextureAsset = serde_json::from_slice(&data)
.with_context(|| format!("deserializing texture '{}'", entry.file_path))?;
index.textures.push(tex);
}
"materials" => {
let data = PackVerifier::extract_file(bytes, &entry.file_path)
.with_context(|| format!("extracting material '{}'", entry.file_path))?;
let mat: MaterialDef = serde_json::from_slice(&data)
.with_context(|| format!("deserializing material '{}'", entry.file_path))?;
index.materials.push(mat);
}
"presets" => {
let data = PackVerifier::extract_file(bytes, &entry.file_path)
.with_context(|| format!("extracting preset '{}'", entry.file_path))?;
let preset: MorphPreset = serde_json::from_slice(&data)
.with_context(|| format!("deserializing preset '{}'", entry.file_path))?;
index.presets.push(preset);
}
"targets" => {
index.target_names.push(entry.name.clone());
}
other => {
let _ = other;
}
}
}
Ok(index)
}
pub fn build_alpha_pack() -> Vec<u8> {
build_alpha_pack_inner().unwrap_or_else(|_| Vec::new())
}
fn build_alpha_pack_inner() -> Result<Vec<u8>> {
let mut builder = AssetPackBuilder::new("oxihuman-alpha");
builder
.set_version("0.1.0-alpha")
.set_author("COOLJAPAN OU (Team Kitasan)")
.set_license("Apache-2.0")
.set_description("OxiHuman alpha sample asset pack — body presets and PBR materials.");
builder.add_preset_unchecked(MorphPreset {
name: "Athletic".to_string(),
description: "Well-developed musculature, low body fat, balanced proportions.".to_string(),
params: {
let mut m = HashMap::new();
m.insert("muscle_mass".to_string(), 0.75);
m.insert("body_fat".to_string(), 0.12);
m.insert("height_scale".to_string(), 1.0);
m.insert("shoulder_width".to_string(), 0.6);
m.insert("waist_width".to_string(), 0.38);
m
},
tags: vec![
"fitness".to_string(),
"sport".to_string(),
"body".to_string(),
],
});
builder.add_preset_unchecked(MorphPreset {
name: "Slim".to_string(),
description: "Slender frame with minimal muscle definition and low body fat.".to_string(),
params: {
let mut m = HashMap::new();
m.insert("muscle_mass".to_string(), 0.30);
m.insert("body_fat".to_string(), 0.10);
m.insert("height_scale".to_string(), 1.0);
m.insert("shoulder_width".to_string(), 0.42);
m.insert("waist_width".to_string(), 0.32);
m
},
tags: vec!["slim".to_string(), "body".to_string()],
});
builder.add_preset_unchecked(MorphPreset {
name: "Heavy".to_string(),
description: "Larger frame with higher body fat and increased mass.".to_string(),
params: {
let mut m = HashMap::new();
m.insert("muscle_mass".to_string(), 0.45);
m.insert("body_fat".to_string(), 0.38);
m.insert("height_scale".to_string(), 1.0);
m.insert("shoulder_width".to_string(), 0.68);
m.insert("waist_width".to_string(), 0.62);
m
},
tags: vec![
"heavy".to_string(),
"overweight".to_string(),
"body".to_string(),
],
});
builder.add_preset_unchecked(MorphPreset {
name: "Tall".to_string(),
description: "Above-average height with proportionally elongated limbs.".to_string(),
params: {
let mut m = HashMap::new();
m.insert("muscle_mass".to_string(), 0.50);
m.insert("body_fat".to_string(), 0.18);
m.insert("height_scale".to_string(), 1.20);
m.insert("leg_length".to_string(), 0.65);
m.insert("torso_length".to_string(), 0.60);
m
},
tags: vec!["tall".to_string(), "height".to_string(), "body".to_string()],
});
builder.add_preset_unchecked(MorphPreset {
name: "Short".to_string(),
description: "Below-average height with proportionally compact build.".to_string(),
params: {
let mut m = HashMap::new();
m.insert("muscle_mass".to_string(), 0.50);
m.insert("body_fat".to_string(), 0.18);
m.insert("height_scale".to_string(), 0.82);
m.insert("leg_length".to_string(), 0.45);
m.insert("torso_length".to_string(), 0.44);
m
},
tags: vec![
"short".to_string(),
"height".to_string(),
"body".to_string(),
],
});
builder.add_material_unchecked(MaterialDef {
name: "Skin".to_string(),
albedo_color: [0.87, 0.72, 0.60, 1.0],
metallic: 0.0,
roughness: 0.70,
emissive: [0.0, 0.0, 0.0],
albedo_texture: Some("skin_albedo".to_string()),
normal_texture: Some("skin_normal".to_string()),
});
builder.add_material_unchecked(MaterialDef {
name: "Cloth".to_string(),
albedo_color: [0.40, 0.40, 0.55, 1.0],
metallic: 0.0,
roughness: 0.90,
emissive: [0.0, 0.0, 0.0],
albedo_texture: Some("cloth_albedo".to_string()),
normal_texture: None,
});
builder.add_material_unchecked(MaterialDef {
name: "Metal".to_string(),
albedo_color: [0.80, 0.80, 0.82, 1.0],
metallic: 0.95,
roughness: 0.20,
emissive: [0.0, 0.0, 0.0],
albedo_texture: None,
normal_texture: None,
});
builder.build()
}
fn collect_files_recursive(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, std::path::PathBuf)>,
) -> Result<()> {
for entry in
std::fs::read_dir(dir).with_context(|| format!("reading dir: {}", dir.display()))?
{
let entry = entry.with_context(|| format!("iterating dir: {}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
collect_files_recursive(base, &path, out)?;
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.map_err(|e| anyhow::anyhow!("strip prefix {}: {e}", path.display()))?
.to_string_lossy()
.into_owned();
out.push((rel, path));
}
}
Ok(())
}
pub fn generate_distribution_manifest(pack_dir: &std::path::Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut pairs: Vec<(String, std::path::PathBuf)> = Vec::new();
collect_files_recursive(pack_dir, pack_dir, &mut pairs)?;
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut entries: HashMap<String, String> = HashMap::new();
for (rel, abs) in pairs {
let data =
std::fs::read(&abs).with_context(|| format!("reading file: {}", abs.display()))?;
let hash = hex::encode(Sha256::digest(&data));
entries.insert(rel, hash);
}
let manifest = serde_json::json!({
"schema_version": "0.1.1",
"files": entries,
});
serde_json::to_string_pretty(&manifest).context("serializing distribution manifest")
}
pub fn verify_distribution_manifest(
manifest_json: &str,
pack_dir: &std::path::Path,
) -> Result<bool> {
use sha2::{Digest, Sha256};
let manifest: serde_json::Value =
serde_json::from_str(manifest_json).context("parsing distribution manifest JSON")?;
let files = manifest["files"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("manifest missing 'files' object"))?;
for (rel_path, expected_value) in files {
let expected = expected_value
.as_str()
.ok_or_else(|| anyhow::anyhow!("hash not a string for '{}'", rel_path))?;
let full_path = pack_dir.join(rel_path);
if !full_path.exists() {
return Ok(false);
}
let data = std::fs::read(&full_path)
.with_context(|| format!("reading file for verification: {}", full_path.display()))?;
let actual = hex::encode(Sha256::digest(&data));
if actual != expected {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_1x1_texture(name: &str) -> TextureAsset {
TextureAsset {
name: name.to_string(),
width: 1,
height: 1,
channels: 3,
data: vec![255, 0, 0],
format: TextureFormat::Png,
}
}
fn make_simple_preset(name: &str) -> MorphPreset {
let mut params = HashMap::new();
params.insert("height_scale".to_string(), 1.0);
MorphPreset {
name: name.to_string(),
description: "Test preset".to_string(),
params,
tags: vec!["test".to_string()],
}
}
fn make_simple_material(name: &str) -> MaterialDef {
MaterialDef {
name: name.to_string(),
albedo_color: [0.8, 0.6, 0.4, 1.0],
metallic: 0.0,
roughness: 0.5,
emissive: [0.0, 0.0, 0.0],
albedo_texture: None,
normal_texture: None,
}
}
#[test]
fn empty_builder_produces_valid_oxp() {
let builder = AssetPackBuilder::new("empty-pack");
let bytes = builder.build().expect("should succeed");
assert!(!bytes.is_empty());
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.name, "empty-pack");
}
#[test]
fn single_preset_round_trip() {
let mut builder = AssetPackBuilder::new("preset-pack");
builder
.add_preset(make_simple_preset("Alpha"))
.expect("should succeed");
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.presets.len(), 1);
assert_eq!(index.presets[0].name, "Alpha");
}
#[test]
fn multiple_presets_round_trip() {
let mut builder = AssetPackBuilder::new("multi-preset");
for name in &["A", "B", "C"] {
builder
.add_preset(make_simple_preset(name))
.expect("should succeed");
}
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.presets.len(), 3);
let names: Vec<&str> = index.presets.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"A"));
assert!(names.contains(&"B"));
assert!(names.contains(&"C"));
}
#[test]
fn texture_round_trip() {
let mut builder = AssetPackBuilder::new("tex-pack");
builder
.add_texture(make_1x1_texture("red"))
.expect("should succeed");
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.textures.len(), 1);
assert_eq!(index.textures[0].name, "red");
assert_eq!(index.textures[0].width, 1);
assert_eq!(index.textures[0].channels, 3);
}
#[test]
fn material_round_trip() {
let mut builder = AssetPackBuilder::new("mat-pack");
builder
.add_material(make_simple_material("chrome"))
.expect("should succeed");
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.materials.len(), 1);
assert_eq!(index.materials[0].name, "chrome");
assert!((index.materials[0].roughness - 0.5).abs() < 1e-6);
}
#[test]
fn mixed_entries_round_trip() {
let mut builder = AssetPackBuilder::new("mixed-pack");
builder
.add_preset(make_simple_preset("P1"))
.expect("should succeed");
builder
.add_texture(make_1x1_texture("T1"))
.expect("should succeed");
builder
.add_material(make_simple_material("M1"))
.expect("should succeed");
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.presets.len(), 1);
assert_eq!(index.textures.len(), 1);
assert_eq!(index.materials.len(), 1);
}
#[test]
fn target_delta_round_trip() {
let mut builder = AssetPackBuilder::new("delta-pack");
builder.add_target(TargetDelta {
name: "head_big.target".to_string(),
data: vec![1, 2, 3, 4, 5],
});
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.target_names.len(), 1);
assert_eq!(index.target_names[0], "head_big.target");
}
#[test]
fn alpha_pack_not_empty() {
let bytes = build_alpha_pack();
assert!(!bytes.is_empty(), "alpha pack must produce bytes");
}
#[test]
fn alpha_pack_has_five_presets() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.presets.len(), 5);
}
#[test]
fn alpha_pack_has_three_materials() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.materials.len(), 3);
}
#[test]
fn alpha_pack_preset_names() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
let names: Vec<&str> = index.presets.iter().map(|p| p.name.as_str()).collect();
for expected in &["Athletic", "Slim", "Heavy", "Tall", "Short"] {
assert!(names.contains(expected), "missing preset: {}", expected);
}
}
#[test]
fn alpha_pack_material_names() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
let names: Vec<&str> = index.materials.iter().map(|m| m.name.as_str()).collect();
for expected in &["Skin", "Cloth", "Metal"] {
assert!(names.contains(expected), "missing material: {}", expected);
}
}
#[test]
fn alpha_pack_manifest_author() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert!(
index.author.contains("COOLJAPAN"),
"unexpected author: {}",
index.author
);
}
#[test]
fn alpha_pack_manifest_license() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert!(!index.license.is_empty());
}
#[test]
fn alpha_pack_integrity_ok() {
let bytes = build_alpha_pack();
assert!(load_pack_from_bytes(&bytes).is_ok());
}
#[test]
fn texture_validation_wrong_length() {
let tex = TextureAsset {
name: "bad".to_string(),
width: 4,
height: 4,
channels: 3,
data: vec![0u8; 10], format: TextureFormat::Png,
};
assert!(tex.validate().is_err());
}
#[test]
fn texture_validation_zero_dimension() {
let tex = TextureAsset {
name: "bad".to_string(),
width: 0,
height: 1,
channels: 3,
data: vec![],
format: TextureFormat::Jpeg,
};
assert!(tex.validate().is_err());
}
#[test]
fn texture_validation_empty_name() {
let tex = TextureAsset {
name: String::new(),
width: 1,
height: 1,
channels: 3,
data: vec![0, 0, 0],
format: TextureFormat::Png,
};
assert!(tex.validate().is_err());
}
#[test]
fn material_validation_metallic_out_of_range() {
let mat = MaterialDef {
name: "bad".to_string(),
albedo_color: [1.0, 0.0, 0.0, 1.0],
metallic: 1.5,
roughness: 0.5,
emissive: [0.0; 3],
albedo_texture: None,
normal_texture: None,
};
assert!(mat.validate().is_err());
}
#[test]
fn material_validation_roughness_out_of_range() {
let mat = MaterialDef {
name: "bad".to_string(),
albedo_color: [1.0, 0.0, 0.0, 1.0],
metallic: 0.5,
roughness: -0.1,
emissive: [0.0; 3],
albedo_texture: None,
normal_texture: None,
};
assert!(mat.validate().is_err());
}
#[test]
fn material_validation_empty_name() {
let mat = MaterialDef {
name: String::new(),
albedo_color: [1.0, 0.0, 0.0, 1.0],
metallic: 0.0,
roughness: 0.5,
emissive: [0.0; 3],
albedo_texture: None,
normal_texture: None,
};
assert!(mat.validate().is_err());
}
#[test]
fn preset_validation_empty_name() {
let preset = MorphPreset {
name: String::new(),
description: "desc".to_string(),
params: HashMap::new(),
tags: vec![],
};
assert!(preset.validate().is_err());
}
#[test]
fn index_total_bytes() {
let mut builder = AssetPackBuilder::new("size-pack");
builder
.add_preset(make_simple_preset("X"))
.expect("should succeed");
let bytes = builder.build().expect("should succeed");
let expected_len = bytes.len();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.total_bytes, expected_len);
}
#[test]
fn builder_metadata_in_index() {
let mut builder = AssetPackBuilder::new("meta-pack");
builder
.set_author("Alice")
.set_version("2.0.0")
.set_license("MIT")
.set_description("A test pack");
let bytes = builder.build().expect("should succeed");
let index = load_pack_from_bytes(&bytes).expect("should succeed");
assert_eq!(index.author, "Alice");
assert_eq!(index.version, "2.0.0");
assert_eq!(index.license, "MIT");
assert_eq!(index.description, "A test pack");
}
#[test]
fn corrupted_bytes_rejected() {
let mut builder = AssetPackBuilder::new("pack");
builder
.add_preset(make_simple_preset("P"))
.expect("should succeed");
let mut bytes = builder.build().expect("should succeed");
let mid = bytes.len() / 2;
bytes[mid] ^= 0xFF;
assert!(load_pack_from_bytes(&bytes).is_err());
}
#[test]
fn texture_format_helpers() {
assert_eq!(TextureFormat::Png.extension(), "png");
assert_eq!(TextureFormat::Jpeg.extension(), "jpg");
assert_eq!(TextureFormat::Exr.extension(), "exr");
assert_eq!(TextureFormat::Png.mime_type(), "image/png");
}
#[test]
fn preset_pack_paths_unique() {
let p1 = make_simple_preset("Athletic Body");
let p2 = make_simple_preset("Slim Body");
assert_ne!(p1.pack_path(), p2.pack_path());
}
#[test]
fn alpha_pack_athletic_params() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
let athletic = index
.presets
.iter()
.find(|p| p.name == "Athletic")
.expect("Athletic preset not found");
assert!(
athletic.params.contains_key("muscle_mass"),
"Athletic preset missing muscle_mass param"
);
let mm = athletic.params["muscle_mass"];
assert!(mm > 0.5, "Athletic muscle_mass should be > 0.5, got {}", mm);
}
#[test]
fn alpha_pack_metal_material_metallic() {
let bytes = build_alpha_pack();
let index = load_pack_from_bytes(&bytes).expect("should succeed");
let metal = index
.materials
.iter()
.find(|m| m.name == "Metal")
.expect("Metal material not found");
assert!(
metal.metallic > 0.9,
"Metal material should have metallic > 0.9, got {}",
metal.metallic
);
}
#[test]
fn asset_pack_meta_defaults() {
let meta = AssetPackMeta::default();
assert_eq!(meta.version, "0.1.0");
assert_eq!(meta.license, "Apache-2.0");
assert_eq!(meta.created_at, 0);
}
#[test]
fn test_generate_and_verify_manifest() {
let dir = std::env::temp_dir().join("oxihuman_dist_test_basic");
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("test.bin"), b"hello world").expect("write test.bin");
let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
assert!(
manifest.contains("test.bin"),
"manifest should reference test.bin"
);
assert!(
manifest.contains("schema_version"),
"manifest should have schema_version"
);
let ok = verify_distribution_manifest(&manifest, &dir).expect("verify manifest");
assert!(ok, "fresh manifest must verify successfully");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_manifest_detects_tampered_file() {
let dir = std::env::temp_dir().join("oxihuman_dist_test_tamper");
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("data.bin"), b"original").expect("write data.bin");
let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
std::fs::write(dir.join("data.bin"), b"tampered!").expect("overwrite data.bin");
let ok = verify_distribution_manifest(&manifest, &dir).expect("verify call should not err");
assert!(!ok, "tampered file must cause verification failure");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_manifest_detects_missing_file() {
let dir = std::env::temp_dir().join("oxihuman_dist_test_missing");
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("present.bin"), b"data").expect("write present.bin");
let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
std::fs::remove_file(dir.join("present.bin")).ok();
let ok = verify_distribution_manifest(&manifest, &dir).expect("verify call should not err");
assert!(!ok, "missing file must cause verification failure");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_manifest_schema_version() {
let dir = std::env::temp_dir().join("oxihuman_dist_test_schema");
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("x.bin"), b"x").expect("write x.bin");
let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
let v: serde_json::Value =
serde_json::from_str(&manifest).expect("manifest must be valid JSON");
assert_eq!(
v["schema_version"]
.as_str()
.expect("schema_version must be string"),
"0.1.1"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_manifest_multiple_files() {
let dir = std::env::temp_dir().join("oxihuman_dist_test_multi");
std::fs::create_dir_all(&dir).expect("create dir");
std::fs::write(dir.join("a.bin"), b"alpha").expect("write a.bin");
std::fs::write(dir.join("b.bin"), b"beta").expect("write b.bin");
std::fs::write(dir.join("c.bin"), b"gamma").expect("write c.bin");
let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
let v: serde_json::Value =
serde_json::from_str(&manifest).expect("manifest must be valid JSON");
let files = v["files"].as_object().expect("files must be object");
assert_eq!(files.len(), 3, "should have exactly 3 entries");
let ok = verify_distribution_manifest(&manifest, &dir).expect("verify");
assert!(ok);
std::fs::remove_dir_all(&dir).ok();
}
}