Skip to main content

oxihuman_core/
asset_pack_builder.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Alpha asset pack builder for OxiHuman.
5//!
6//! Provides high-level builder API for creating `.oxp` asset packs that
7//! bundle morph presets, texture assets, and material definitions alongside
8//! conventional target deltas.
9//!
10//! ## Quick start
11//!
12//! ```rust
13//! use oxihuman_core::asset_pack_builder::{AssetPackBuilder, build_alpha_pack};
14//!
15//! // Generate the built-in alpha sample pack
16//! let bytes = build_alpha_pack();
17//! assert!(!bytes.is_empty());
18//!
19//! // Round-trip: load the bytes back into an index
20//! use oxihuman_core::asset_pack_builder::load_pack_from_bytes;
21//! let index = load_pack_from_bytes(&bytes).expect("load failed");
22//! assert_eq!(index.presets.len(), 5);
23//! ```
24
25use std::collections::HashMap;
26
27use anyhow::{bail, Context, Result};
28use serde::{Deserialize, Serialize};
29
30use crate::pack_distribute::{PackBuilder, PackVerifier};
31
32// ── Texture format ───────────────────────────────────────────────────────────
33
34/// Supported texture encoding formats.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub enum TextureFormat {
37    Png,
38    Jpeg,
39    Exr,
40}
41
42impl TextureFormat {
43    fn extension(&self) -> &'static str {
44        match self {
45            TextureFormat::Png => "png",
46            TextureFormat::Jpeg => "jpg",
47            TextureFormat::Exr => "exr",
48        }
49    }
50
51    #[allow(dead_code)]
52    fn mime_type(&self) -> &'static str {
53        match self {
54            TextureFormat::Png => "image/png",
55            TextureFormat::Jpeg => "image/jpeg",
56            TextureFormat::Exr => "image/x-exr",
57        }
58    }
59}
60
61// ── Asset types ──────────────────────────────────────────────────────────────
62
63/// A texture asset stored inside an asset pack.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TextureAsset {
66    /// Logical name for this texture (e.g. `"skin_albedo"`).
67    pub name: String,
68    /// Width in pixels.
69    pub width: u32,
70    /// Height in pixels.
71    pub height: u32,
72    /// Number of channels (1 = grey, 3 = RGB, 4 = RGBA).
73    pub channels: u8,
74    /// Raw pixel bytes, length must equal `width * height * channels`.
75    pub data: Vec<u8>,
76    /// Encoding format of the stored data.
77    pub format: TextureFormat,
78}
79
80impl TextureAsset {
81    /// Validate that the data length is consistent with the declared dimensions.
82    pub fn validate(&self) -> Result<()> {
83        if self.name.is_empty() {
84            bail!("texture name must not be empty");
85        }
86        if self.width == 0 || self.height == 0 {
87            bail!("texture dimensions must be non-zero");
88        }
89        if self.channels == 0 || self.channels > 4 {
90            bail!("texture channels must be 1–4, got {}", self.channels);
91        }
92        let expected = self.width as usize * self.height as usize * self.channels as usize;
93        if self.data.len() != expected {
94            bail!(
95                "texture '{}': expected {} bytes ({} x {} x {}), got {}",
96                self.name,
97                expected,
98                self.width,
99                self.height,
100                self.channels,
101                self.data.len()
102            );
103        }
104        Ok(())
105    }
106
107    /// Canonical file path within the pack archive.
108    fn pack_path(&self) -> String {
109        format!("textures/{}.{}", self.name, self.format.extension())
110    }
111}
112
113// ── MaterialDef ──────────────────────────────────────────────────────────────
114
115/// A PBR material definition stored inside an asset pack.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct MaterialDef {
118    /// Logical name (e.g. `"skin"`).
119    pub name: String,
120    /// Base-colour / albedo RGBA in \[0, 1\].
121    pub albedo_color: [f32; 4],
122    /// Metallic factor \[0, 1\].
123    pub metallic: f32,
124    /// Roughness factor \[0, 1\].
125    pub roughness: f32,
126    /// Emissive RGB in \[0, 1\].
127    pub emissive: [f32; 3],
128    /// Optional reference to a texture asset name for albedo.
129    pub albedo_texture: Option<String>,
130    /// Optional reference to a texture asset name for the normal map.
131    pub normal_texture: Option<String>,
132}
133
134impl MaterialDef {
135    /// Validate that scalar factors are in range.
136    pub fn validate(&self) -> Result<()> {
137        if self.name.is_empty() {
138            bail!("material name must not be empty");
139        }
140        if !(0.0..=1.0).contains(&self.metallic) {
141            bail!(
142                "material '{}': metallic {} is out of [0,1]",
143                self.name,
144                self.metallic
145            );
146        }
147        if !(0.0..=1.0).contains(&self.roughness) {
148            bail!(
149                "material '{}': roughness {} is out of [0,1]",
150                self.name,
151                self.roughness
152            );
153        }
154        Ok(())
155    }
156
157    /// Canonical file path within the pack archive.
158    fn pack_path(&self) -> String {
159        format!("materials/{}.json", self.name)
160    }
161}
162
163// ── MorphPreset ──────────────────────────────────────────────────────────────
164
165/// A named collection of morph parameter values forming a body preset.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct MorphPreset {
168    /// Human-readable preset name (e.g. `"Athletic"`).
169    pub name: String,
170    /// Short description of this preset's characteristics.
171    pub description: String,
172    /// Parameter name → value mapping. Values are dimensionless scalars.
173    pub params: HashMap<String, f64>,
174    /// Categorical tags for filtering/search (e.g. `["body", "fitness"]`).
175    pub tags: Vec<String>,
176}
177
178impl MorphPreset {
179    /// Validate that the preset has at least a name.
180    pub fn validate(&self) -> Result<()> {
181        if self.name.is_empty() {
182            bail!("morph preset name must not be empty");
183        }
184        Ok(())
185    }
186
187    /// Canonical file path within the pack archive.
188    fn pack_path(&self) -> String {
189        let slug: String = self
190            .name
191            .to_lowercase()
192            .chars()
193            .map(|c| if c.is_alphanumeric() { c } else { '_' })
194            .collect();
195        format!("presets/{}.json", slug)
196    }
197}
198
199// ── AssetPackEntry ────────────────────────────────────────────────────────────
200
201/// A single entry that can be stored in an asset pack.
202#[derive(Debug, Clone)]
203pub enum AssetPackEntry {
204    /// Raw target-delta data (binary blob).
205    Target(TargetDelta),
206    /// A texture image asset.
207    Texture(TextureAsset),
208    /// A PBR material definition.
209    Material(MaterialDef),
210    /// A morph-parameter preset.
211    Preset(MorphPreset),
212}
213
214/// Raw morph target delta stored as binary data.
215#[derive(Debug, Clone)]
216pub struct TargetDelta {
217    /// Logical name for this target.
218    pub name: String,
219    /// Binary data (e.g. OBJ target offsets).
220    pub data: Vec<u8>,
221}
222
223// ── Pack metadata ─────────────────────────────────────────────────────────────
224
225/// Metadata attached to the top-level pack manifest.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct AssetPackMeta {
228    /// Pack format version string.
229    pub version: String,
230    /// Author or organisation name.
231    pub author: String,
232    /// SPDX license identifier.
233    pub license: String,
234    /// Free-form description of the pack contents.
235    pub description: String,
236    /// Creation timestamp (Unix seconds). `0` if not set.
237    pub created_at: u64,
238}
239
240impl Default for AssetPackMeta {
241    fn default() -> Self {
242        Self {
243            version: "0.1.0".to_string(),
244            author: String::new(),
245            license: "Apache-2.0".to_string(),
246            description: String::new(),
247            created_at: 0,
248        }
249    }
250}
251
252// ── AssetPackBuilder ──────────────────────────────────────────────────────────
253
254/// Builder for creating `.oxp` asset packs with typed entries.
255///
256/// # Example
257/// ```rust
258/// use oxihuman_core::asset_pack_builder::{AssetPackBuilder, MorphPreset};
259/// use std::collections::HashMap;
260///
261/// let mut builder = AssetPackBuilder::new("my-pack");
262/// let mut params = HashMap::new();
263/// params.insert("height".to_string(), 1.8);
264/// builder.add_preset(MorphPreset {
265///     name: "Tall".to_string(),
266///     description: "Above-average stature".to_string(),
267///     params,
268///     tags: vec!["height".to_string()],
269/// });
270/// let bytes = builder.build().expect("build failed");
271/// assert!(!bytes.is_empty());
272/// ```
273pub struct AssetPackBuilder {
274    name: String,
275    meta: AssetPackMeta,
276    entries: Vec<AssetPackEntry>,
277}
278
279impl AssetPackBuilder {
280    /// Create a new builder for a pack with the given name.
281    pub fn new(name: &str) -> Self {
282        Self {
283            name: name.to_string(),
284            meta: AssetPackMeta::default(),
285            entries: Vec::new(),
286        }
287    }
288
289    /// Set pack metadata (version, author, license, description).
290    pub fn set_meta(&mut self, meta: AssetPackMeta) -> &mut Self {
291        self.meta = meta;
292        self
293    }
294
295    /// Convenience: set author string.
296    pub fn set_author(&mut self, author: &str) -> &mut Self {
297        self.meta.author = author.to_string();
298        self
299    }
300
301    /// Convenience: set version string.
302    pub fn set_version(&mut self, version: &str) -> &mut Self {
303        self.meta.version = version.to_string();
304        self
305    }
306
307    /// Convenience: set license identifier.
308    pub fn set_license(&mut self, license: &str) -> &mut Self {
309        self.meta.license = license.to_string();
310        self
311    }
312
313    /// Convenience: set description.
314    pub fn set_description(&mut self, desc: &str) -> &mut Self {
315        self.meta.description = desc.to_string();
316        self
317    }
318
319    /// Add a raw target delta entry.
320    pub fn add_target(&mut self, delta: TargetDelta) -> &mut Self {
321        self.entries.push(AssetPackEntry::Target(delta));
322        self
323    }
324
325    /// Add a texture asset.  The texture is validated before insertion.
326    pub fn add_texture(&mut self, tex: TextureAsset) -> Result<&mut Self> {
327        tex.validate()?;
328        self.entries.push(AssetPackEntry::Texture(tex));
329        Ok(self)
330    }
331
332    /// Add a material definition.  The material is validated before insertion.
333    pub fn add_material(&mut self, mat: MaterialDef) -> Result<&mut Self> {
334        mat.validate()?;
335        self.entries.push(AssetPackEntry::Material(mat));
336        Ok(self)
337    }
338
339    /// Add a morph preset.  The preset is validated before insertion.
340    pub fn add_preset(&mut self, preset: MorphPreset) -> Result<&mut Self> {
341        preset.validate()?;
342        self.entries.push(AssetPackEntry::Preset(preset));
343        Ok(self)
344    }
345
346    /// Unchecked variant for internal use where validation has already run.
347    fn add_preset_unchecked(&mut self, preset: MorphPreset) -> &mut Self {
348        self.entries.push(AssetPackEntry::Preset(preset));
349        self
350    }
351
352    /// Unchecked material variant.
353    fn add_material_unchecked(&mut self, mat: MaterialDef) -> &mut Self {
354        self.entries.push(AssetPackEntry::Material(mat));
355        self
356    }
357
358    /// Serialize all entries into an OXP binary package.
359    pub fn build(&self) -> Result<Vec<u8>> {
360        let mut pack = PackBuilder::new(&self.name, &self.meta.version, &self.meta.author);
361        pack.set_description(&self.meta.description);
362        pack.set_license(&self.meta.license);
363        pack.set_created_at(self.meta.created_at);
364
365        for entry in &self.entries {
366            match entry {
367                AssetPackEntry::Target(delta) => {
368                    pack.add_target_file(&delta.name, "targets", &delta.data)
369                        .with_context(|| format!("failed to add target '{}'", delta.name))?;
370                }
371                AssetPackEntry::Texture(tex) => {
372                    let json = serde_json::to_vec(tex)
373                        .with_context(|| format!("failed to serialize texture '{}'", tex.name))?;
374                    pack.add_target_file(&tex.pack_path(), "textures", &json)
375                        .with_context(|| format!("failed to add texture '{}'", tex.name))?;
376                }
377                AssetPackEntry::Material(mat) => {
378                    let json = serde_json::to_vec(mat)
379                        .with_context(|| format!("failed to serialize material '{}'", mat.name))?;
380                    pack.add_target_file(&mat.pack_path(), "materials", &json)
381                        .with_context(|| format!("failed to add material '{}'", mat.name))?;
382                }
383                AssetPackEntry::Preset(preset) => {
384                    let json = serde_json::to_vec(preset)
385                        .with_context(|| format!("failed to serialize preset '{}'", preset.name))?;
386                    pack.add_target_file(&preset.pack_path(), "presets", &json)
387                        .with_context(|| format!("failed to add preset '{}'", preset.name))?;
388                }
389            }
390        }
391
392        pack.build()
393    }
394}
395
396// ── AssetPackIndex ────────────────────────────────────────────────────────────
397
398/// Summary index built by scanning a loaded OXP pack.
399///
400/// Returned by [`load_pack_from_bytes`].
401#[derive(Debug, Clone)]
402pub struct AssetPackIndex {
403    /// Pack name from the manifest.
404    pub name: String,
405    /// Pack version from the manifest.
406    pub version: String,
407    /// Author from the manifest.
408    pub author: String,
409    /// License from the manifest.
410    pub license: String,
411    /// Description from the manifest.
412    pub description: String,
413    /// All texture assets decoded from the pack.
414    pub textures: Vec<TextureAsset>,
415    /// All material definitions decoded from the pack.
416    pub materials: Vec<MaterialDef>,
417    /// All morph presets decoded from the pack.
418    pub presets: Vec<MorphPreset>,
419    /// Names of raw target-delta entries.
420    pub target_names: Vec<String>,
421    /// Total byte size of the raw package.
422    pub total_bytes: usize,
423}
424
425// ── Public API ────────────────────────────────────────────────────────────────
426
427/// Load an OXP package from raw bytes and return a scanned index.
428///
429/// The integrity hash is verified before any deserialization occurs.
430pub fn load_pack_from_bytes(bytes: &[u8]) -> Result<AssetPackIndex> {
431    // Integrity check first
432    let manifest =
433        PackVerifier::verify_integrity(bytes).with_context(|| "OXP integrity check failed")?;
434
435    let mut index = AssetPackIndex {
436        name: manifest.name.clone(),
437        version: manifest.version.clone(),
438        author: manifest.author.clone(),
439        license: manifest.license.clone(),
440        description: manifest.description.clone(),
441        textures: Vec::new(),
442        materials: Vec::new(),
443        presets: Vec::new(),
444        target_names: Vec::new(),
445        total_bytes: bytes.len(),
446    };
447
448    // Scan every target entry by category prefix
449    for entry in &manifest.targets {
450        match entry.category.as_str() {
451            "textures" => {
452                let data = PackVerifier::extract_file(bytes, &entry.file_path)
453                    .with_context(|| format!("extracting texture '{}'", entry.file_path))?;
454                let tex: TextureAsset = serde_json::from_slice(&data)
455                    .with_context(|| format!("deserializing texture '{}'", entry.file_path))?;
456                index.textures.push(tex);
457            }
458            "materials" => {
459                let data = PackVerifier::extract_file(bytes, &entry.file_path)
460                    .with_context(|| format!("extracting material '{}'", entry.file_path))?;
461                let mat: MaterialDef = serde_json::from_slice(&data)
462                    .with_context(|| format!("deserializing material '{}'", entry.file_path))?;
463                index.materials.push(mat);
464            }
465            "presets" => {
466                let data = PackVerifier::extract_file(bytes, &entry.file_path)
467                    .with_context(|| format!("extracting preset '{}'", entry.file_path))?;
468                let preset: MorphPreset = serde_json::from_slice(&data)
469                    .with_context(|| format!("deserializing preset '{}'", entry.file_path))?;
470                index.presets.push(preset);
471            }
472            "targets" => {
473                index.target_names.push(entry.name.clone());
474            }
475            other => {
476                // Unknown category: silently skip (forward-compatible)
477                let _ = other;
478            }
479        }
480    }
481
482    Ok(index)
483}
484
485// ── Alpha pack factory ────────────────────────────────────────────────────────
486
487/// Build the built-in alpha sample asset pack.
488///
489/// Contains:
490/// - 5 body morph presets: Athletic, Slim, Heavy, Tall, Short
491/// - 3 PBR material definitions: Skin, Cloth, Metal
492/// - Pack manifest with version, author, and license info
493///
494/// Returns the serialized OXP bytes.  This function never fails.
495pub fn build_alpha_pack() -> Vec<u8> {
496    build_alpha_pack_inner().unwrap_or_else(|_| Vec::new())
497}
498
499fn build_alpha_pack_inner() -> Result<Vec<u8>> {
500    let mut builder = AssetPackBuilder::new("oxihuman-alpha");
501    builder
502        .set_version("0.1.0-alpha")
503        .set_author("COOLJAPAN OU (Team Kitasan)")
504        .set_license("Apache-2.0")
505        .set_description("OxiHuman alpha sample asset pack — body presets and PBR materials.");
506
507    // ── 5 Morph Presets ─────────────────────────────────────────────────────
508
509    builder.add_preset_unchecked(MorphPreset {
510        name: "Athletic".to_string(),
511        description: "Well-developed musculature, low body fat, balanced proportions.".to_string(),
512        params: {
513            let mut m = HashMap::new();
514            m.insert("muscle_mass".to_string(), 0.75);
515            m.insert("body_fat".to_string(), 0.12);
516            m.insert("height_scale".to_string(), 1.0);
517            m.insert("shoulder_width".to_string(), 0.6);
518            m.insert("waist_width".to_string(), 0.38);
519            m
520        },
521        tags: vec![
522            "fitness".to_string(),
523            "sport".to_string(),
524            "body".to_string(),
525        ],
526    });
527
528    builder.add_preset_unchecked(MorphPreset {
529        name: "Slim".to_string(),
530        description: "Slender frame with minimal muscle definition and low body fat.".to_string(),
531        params: {
532            let mut m = HashMap::new();
533            m.insert("muscle_mass".to_string(), 0.30);
534            m.insert("body_fat".to_string(), 0.10);
535            m.insert("height_scale".to_string(), 1.0);
536            m.insert("shoulder_width".to_string(), 0.42);
537            m.insert("waist_width".to_string(), 0.32);
538            m
539        },
540        tags: vec!["slim".to_string(), "body".to_string()],
541    });
542
543    builder.add_preset_unchecked(MorphPreset {
544        name: "Heavy".to_string(),
545        description: "Larger frame with higher body fat and increased mass.".to_string(),
546        params: {
547            let mut m = HashMap::new();
548            m.insert("muscle_mass".to_string(), 0.45);
549            m.insert("body_fat".to_string(), 0.38);
550            m.insert("height_scale".to_string(), 1.0);
551            m.insert("shoulder_width".to_string(), 0.68);
552            m.insert("waist_width".to_string(), 0.62);
553            m
554        },
555        tags: vec![
556            "heavy".to_string(),
557            "overweight".to_string(),
558            "body".to_string(),
559        ],
560    });
561
562    builder.add_preset_unchecked(MorphPreset {
563        name: "Tall".to_string(),
564        description: "Above-average height with proportionally elongated limbs.".to_string(),
565        params: {
566            let mut m = HashMap::new();
567            m.insert("muscle_mass".to_string(), 0.50);
568            m.insert("body_fat".to_string(), 0.18);
569            m.insert("height_scale".to_string(), 1.20);
570            m.insert("leg_length".to_string(), 0.65);
571            m.insert("torso_length".to_string(), 0.60);
572            m
573        },
574        tags: vec!["tall".to_string(), "height".to_string(), "body".to_string()],
575    });
576
577    builder.add_preset_unchecked(MorphPreset {
578        name: "Short".to_string(),
579        description: "Below-average height with proportionally compact build.".to_string(),
580        params: {
581            let mut m = HashMap::new();
582            m.insert("muscle_mass".to_string(), 0.50);
583            m.insert("body_fat".to_string(), 0.18);
584            m.insert("height_scale".to_string(), 0.82);
585            m.insert("leg_length".to_string(), 0.45);
586            m.insert("torso_length".to_string(), 0.44);
587            m
588        },
589        tags: vec![
590            "short".to_string(),
591            "height".to_string(),
592            "body".to_string(),
593        ],
594    });
595
596    // ── 3 Material Definitions ───────────────────────────────────────────────
597
598    builder.add_material_unchecked(MaterialDef {
599        name: "Skin".to_string(),
600        albedo_color: [0.87, 0.72, 0.60, 1.0],
601        metallic: 0.0,
602        roughness: 0.70,
603        emissive: [0.0, 0.0, 0.0],
604        albedo_texture: Some("skin_albedo".to_string()),
605        normal_texture: Some("skin_normal".to_string()),
606    });
607
608    builder.add_material_unchecked(MaterialDef {
609        name: "Cloth".to_string(),
610        albedo_color: [0.40, 0.40, 0.55, 1.0],
611        metallic: 0.0,
612        roughness: 0.90,
613        emissive: [0.0, 0.0, 0.0],
614        albedo_texture: Some("cloth_albedo".to_string()),
615        normal_texture: None,
616    });
617
618    builder.add_material_unchecked(MaterialDef {
619        name: "Metal".to_string(),
620        albedo_color: [0.80, 0.80, 0.82, 1.0],
621        metallic: 0.95,
622        roughness: 0.20,
623        emissive: [0.0, 0.0, 0.0],
624        albedo_texture: None,
625        normal_texture: None,
626    });
627
628    builder.build()
629}
630
631// ── Distribution manifest ─────────────────────────────────────────────────────
632
633/// Recursively collect all file paths within a directory.
634///
635/// Returns a sorted list of `(relative_path_string, absolute_path)` pairs.
636fn collect_files_recursive(
637    base: &std::path::Path,
638    dir: &std::path::Path,
639    out: &mut Vec<(String, std::path::PathBuf)>,
640) -> Result<()> {
641    for entry in
642        std::fs::read_dir(dir).with_context(|| format!("reading dir: {}", dir.display()))?
643    {
644        let entry = entry.with_context(|| format!("iterating dir: {}", dir.display()))?;
645        let path = entry.path();
646        if path.is_dir() {
647            collect_files_recursive(base, &path, out)?;
648        } else if path.is_file() {
649            let rel = path
650                .strip_prefix(base)
651                .map_err(|e| anyhow::anyhow!("strip prefix {}: {e}", path.display()))?
652                .to_string_lossy()
653                .into_owned();
654            out.push((rel, path));
655        }
656    }
657    Ok(())
658}
659
660/// Generate a distribution manifest JSON for an asset pack directory.
661///
662/// Walks `pack_dir` recursively and records the SHA-256 hash of every file.
663/// Returns a pretty-printed JSON string suitable for saving as
664/// `<pack-name>.dist-manifest.json`.
665///
666/// # Errors
667///
668/// Returns an error if any file cannot be read or if JSON serialization fails.
669pub fn generate_distribution_manifest(pack_dir: &std::path::Path) -> Result<String> {
670    use sha2::{Digest, Sha256};
671
672    let mut pairs: Vec<(String, std::path::PathBuf)> = Vec::new();
673    collect_files_recursive(pack_dir, pack_dir, &mut pairs)?;
674    // Sort for deterministic output.
675    pairs.sort_by(|a, b| a.0.cmp(&b.0));
676
677    let mut entries: HashMap<String, String> = HashMap::new();
678    for (rel, abs) in pairs {
679        let data =
680            std::fs::read(&abs).with_context(|| format!("reading file: {}", abs.display()))?;
681        let hash = hex::encode(Sha256::digest(&data));
682        entries.insert(rel, hash);
683    }
684
685    let manifest = serde_json::json!({
686        "schema_version": "0.1.1",
687        "files": entries,
688    });
689    serde_json::to_string_pretty(&manifest).context("serializing distribution manifest")
690}
691
692/// Verify a distribution manifest against the actual files in `pack_dir`.
693///
694/// Returns `true` if every file listed in the manifest exists on disk and
695/// its SHA-256 hash matches the recorded value.  Returns `false` (not an
696/// error) when a file is missing or its hash differs.
697///
698/// # Errors
699///
700/// Returns an error only for I/O failures or malformed manifest JSON.
701pub fn verify_distribution_manifest(
702    manifest_json: &str,
703    pack_dir: &std::path::Path,
704) -> Result<bool> {
705    use sha2::{Digest, Sha256};
706
707    let manifest: serde_json::Value =
708        serde_json::from_str(manifest_json).context("parsing distribution manifest JSON")?;
709    let files = manifest["files"]
710        .as_object()
711        .ok_or_else(|| anyhow::anyhow!("manifest missing 'files' object"))?;
712
713    for (rel_path, expected_value) in files {
714        let expected = expected_value
715            .as_str()
716            .ok_or_else(|| anyhow::anyhow!("hash not a string for '{}'", rel_path))?;
717        let full_path = pack_dir.join(rel_path);
718        if !full_path.exists() {
719            return Ok(false);
720        }
721        let data = std::fs::read(&full_path)
722            .with_context(|| format!("reading file for verification: {}", full_path.display()))?;
723        let actual = hex::encode(Sha256::digest(&data));
724        if actual != expected {
725            return Ok(false);
726        }
727    }
728    Ok(true)
729}
730
731// ── Tests ─────────────────────────────────────────────────────────────────────
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    // ── Helper ───────────────────────────────────────────────────────────────
738
739    fn make_1x1_texture(name: &str) -> TextureAsset {
740        TextureAsset {
741            name: name.to_string(),
742            width: 1,
743            height: 1,
744            channels: 3,
745            data: vec![255, 0, 0],
746            format: TextureFormat::Png,
747        }
748    }
749
750    fn make_simple_preset(name: &str) -> MorphPreset {
751        let mut params = HashMap::new();
752        params.insert("height_scale".to_string(), 1.0);
753        MorphPreset {
754            name: name.to_string(),
755            description: "Test preset".to_string(),
756            params,
757            tags: vec!["test".to_string()],
758        }
759    }
760
761    fn make_simple_material(name: &str) -> MaterialDef {
762        MaterialDef {
763            name: name.to_string(),
764            albedo_color: [0.8, 0.6, 0.4, 1.0],
765            metallic: 0.0,
766            roughness: 0.5,
767            emissive: [0.0, 0.0, 0.0],
768            albedo_texture: None,
769            normal_texture: None,
770        }
771    }
772
773    // 1. Empty builder produces valid OXP
774    #[test]
775    fn empty_builder_produces_valid_oxp() {
776        let builder = AssetPackBuilder::new("empty-pack");
777        let bytes = builder.build().expect("should succeed");
778        assert!(!bytes.is_empty());
779        // Integrity must pass
780        let index = load_pack_from_bytes(&bytes).expect("should succeed");
781        assert_eq!(index.name, "empty-pack");
782    }
783
784    // 2. Build with one preset round-trips
785    #[test]
786    fn single_preset_round_trip() {
787        let mut builder = AssetPackBuilder::new("preset-pack");
788        builder
789            .add_preset(make_simple_preset("Alpha"))
790            .expect("should succeed");
791        let bytes = builder.build().expect("should succeed");
792        let index = load_pack_from_bytes(&bytes).expect("should succeed");
793        assert_eq!(index.presets.len(), 1);
794        assert_eq!(index.presets[0].name, "Alpha");
795    }
796
797    // 3. Multiple presets preserved in order
798    #[test]
799    fn multiple_presets_round_trip() {
800        let mut builder = AssetPackBuilder::new("multi-preset");
801        for name in &["A", "B", "C"] {
802            builder
803                .add_preset(make_simple_preset(name))
804                .expect("should succeed");
805        }
806        let bytes = builder.build().expect("should succeed");
807        let index = load_pack_from_bytes(&bytes).expect("should succeed");
808        assert_eq!(index.presets.len(), 3);
809        let names: Vec<&str> = index.presets.iter().map(|p| p.name.as_str()).collect();
810        assert!(names.contains(&"A"));
811        assert!(names.contains(&"B"));
812        assert!(names.contains(&"C"));
813    }
814
815    // 4. Texture round-trip
816    #[test]
817    fn texture_round_trip() {
818        let mut builder = AssetPackBuilder::new("tex-pack");
819        builder
820            .add_texture(make_1x1_texture("red"))
821            .expect("should succeed");
822        let bytes = builder.build().expect("should succeed");
823        let index = load_pack_from_bytes(&bytes).expect("should succeed");
824        assert_eq!(index.textures.len(), 1);
825        assert_eq!(index.textures[0].name, "red");
826        assert_eq!(index.textures[0].width, 1);
827        assert_eq!(index.textures[0].channels, 3);
828    }
829
830    // 5. Material round-trip
831    #[test]
832    fn material_round_trip() {
833        let mut builder = AssetPackBuilder::new("mat-pack");
834        builder
835            .add_material(make_simple_material("chrome"))
836            .expect("should succeed");
837        let bytes = builder.build().expect("should succeed");
838        let index = load_pack_from_bytes(&bytes).expect("should succeed");
839        assert_eq!(index.materials.len(), 1);
840        assert_eq!(index.materials[0].name, "chrome");
841        assert!((index.materials[0].roughness - 0.5).abs() < 1e-6);
842    }
843
844    // 6. Mixed entries all decoded
845    #[test]
846    fn mixed_entries_round_trip() {
847        let mut builder = AssetPackBuilder::new("mixed-pack");
848        builder
849            .add_preset(make_simple_preset("P1"))
850            .expect("should succeed");
851        builder
852            .add_texture(make_1x1_texture("T1"))
853            .expect("should succeed");
854        builder
855            .add_material(make_simple_material("M1"))
856            .expect("should succeed");
857        let bytes = builder.build().expect("should succeed");
858        let index = load_pack_from_bytes(&bytes).expect("should succeed");
859        assert_eq!(index.presets.len(), 1);
860        assert_eq!(index.textures.len(), 1);
861        assert_eq!(index.materials.len(), 1);
862    }
863
864    // 7. Target delta preserved
865    #[test]
866    fn target_delta_round_trip() {
867        let mut builder = AssetPackBuilder::new("delta-pack");
868        builder.add_target(TargetDelta {
869            name: "head_big.target".to_string(),
870            data: vec![1, 2, 3, 4, 5],
871        });
872        let bytes = builder.build().expect("should succeed");
873        let index = load_pack_from_bytes(&bytes).expect("should succeed");
874        assert_eq!(index.target_names.len(), 1);
875        assert_eq!(index.target_names[0], "head_big.target");
876    }
877
878    // 8. Alpha pack generation succeeds and is non-empty
879    #[test]
880    fn alpha_pack_not_empty() {
881        let bytes = build_alpha_pack();
882        assert!(!bytes.is_empty(), "alpha pack must produce bytes");
883    }
884
885    // 9. Alpha pack has exactly 5 presets
886    #[test]
887    fn alpha_pack_has_five_presets() {
888        let bytes = build_alpha_pack();
889        let index = load_pack_from_bytes(&bytes).expect("should succeed");
890        assert_eq!(index.presets.len(), 5);
891    }
892
893    // 10. Alpha pack has exactly 3 materials
894    #[test]
895    fn alpha_pack_has_three_materials() {
896        let bytes = build_alpha_pack();
897        let index = load_pack_from_bytes(&bytes).expect("should succeed");
898        assert_eq!(index.materials.len(), 3);
899    }
900
901    // 11. Alpha pack preset names are correct
902    #[test]
903    fn alpha_pack_preset_names() {
904        let bytes = build_alpha_pack();
905        let index = load_pack_from_bytes(&bytes).expect("should succeed");
906        let names: Vec<&str> = index.presets.iter().map(|p| p.name.as_str()).collect();
907        for expected in &["Athletic", "Slim", "Heavy", "Tall", "Short"] {
908            assert!(names.contains(expected), "missing preset: {}", expected);
909        }
910    }
911
912    // 12. Alpha pack material names are correct
913    #[test]
914    fn alpha_pack_material_names() {
915        let bytes = build_alpha_pack();
916        let index = load_pack_from_bytes(&bytes).expect("should succeed");
917        let names: Vec<&str> = index.materials.iter().map(|m| m.name.as_str()).collect();
918        for expected in &["Skin", "Cloth", "Metal"] {
919            assert!(names.contains(expected), "missing material: {}", expected);
920        }
921    }
922
923    // 13. Alpha pack manifest has correct author
924    #[test]
925    fn alpha_pack_manifest_author() {
926        let bytes = build_alpha_pack();
927        let index = load_pack_from_bytes(&bytes).expect("should succeed");
928        assert!(
929            index.author.contains("COOLJAPAN"),
930            "unexpected author: {}",
931            index.author
932        );
933    }
934
935    // 14. Alpha pack manifest has license info
936    #[test]
937    fn alpha_pack_manifest_license() {
938        let bytes = build_alpha_pack();
939        let index = load_pack_from_bytes(&bytes).expect("should succeed");
940        assert!(!index.license.is_empty());
941    }
942
943    // 15. Alpha pack integrity verified
944    #[test]
945    fn alpha_pack_integrity_ok() {
946        let bytes = build_alpha_pack();
947        // load_pack_from_bytes runs verify_integrity internally
948        assert!(load_pack_from_bytes(&bytes).is_ok());
949    }
950
951    // 16. Texture validation rejects wrong data length
952    #[test]
953    fn texture_validation_wrong_length() {
954        let tex = TextureAsset {
955            name: "bad".to_string(),
956            width: 4,
957            height: 4,
958            channels: 3,
959            data: vec![0u8; 10], // too short
960            format: TextureFormat::Png,
961        };
962        assert!(tex.validate().is_err());
963    }
964
965    // 17. Texture validation rejects zero dimensions
966    #[test]
967    fn texture_validation_zero_dimension() {
968        let tex = TextureAsset {
969            name: "bad".to_string(),
970            width: 0,
971            height: 1,
972            channels: 3,
973            data: vec![],
974            format: TextureFormat::Jpeg,
975        };
976        assert!(tex.validate().is_err());
977    }
978
979    // 18. Texture validation rejects empty name
980    #[test]
981    fn texture_validation_empty_name() {
982        let tex = TextureAsset {
983            name: String::new(),
984            width: 1,
985            height: 1,
986            channels: 3,
987            data: vec![0, 0, 0],
988            format: TextureFormat::Png,
989        };
990        assert!(tex.validate().is_err());
991    }
992
993    // 19. Material validation rejects metallic out of range
994    #[test]
995    fn material_validation_metallic_out_of_range() {
996        let mat = MaterialDef {
997            name: "bad".to_string(),
998            albedo_color: [1.0, 0.0, 0.0, 1.0],
999            metallic: 1.5,
1000            roughness: 0.5,
1001            emissive: [0.0; 3],
1002            albedo_texture: None,
1003            normal_texture: None,
1004        };
1005        assert!(mat.validate().is_err());
1006    }
1007
1008    // 20. Material validation rejects roughness out of range
1009    #[test]
1010    fn material_validation_roughness_out_of_range() {
1011        let mat = MaterialDef {
1012            name: "bad".to_string(),
1013            albedo_color: [1.0, 0.0, 0.0, 1.0],
1014            metallic: 0.5,
1015            roughness: -0.1,
1016            emissive: [0.0; 3],
1017            albedo_texture: None,
1018            normal_texture: None,
1019        };
1020        assert!(mat.validate().is_err());
1021    }
1022
1023    // 21. Material validation rejects empty name
1024    #[test]
1025    fn material_validation_empty_name() {
1026        let mat = MaterialDef {
1027            name: String::new(),
1028            albedo_color: [1.0, 0.0, 0.0, 1.0],
1029            metallic: 0.0,
1030            roughness: 0.5,
1031            emissive: [0.0; 3],
1032            albedo_texture: None,
1033            normal_texture: None,
1034        };
1035        assert!(mat.validate().is_err());
1036    }
1037
1038    // 22. Preset validation rejects empty name
1039    #[test]
1040    fn preset_validation_empty_name() {
1041        let preset = MorphPreset {
1042            name: String::new(),
1043            description: "desc".to_string(),
1044            params: HashMap::new(),
1045            tags: vec![],
1046        };
1047        assert!(preset.validate().is_err());
1048    }
1049
1050    // 23. Index total_bytes reflects actual pack size
1051    #[test]
1052    fn index_total_bytes() {
1053        let mut builder = AssetPackBuilder::new("size-pack");
1054        builder
1055            .add_preset(make_simple_preset("X"))
1056            .expect("should succeed");
1057        let bytes = builder.build().expect("should succeed");
1058        let expected_len = bytes.len();
1059        let index = load_pack_from_bytes(&bytes).expect("should succeed");
1060        assert_eq!(index.total_bytes, expected_len);
1061    }
1062
1063    // 24. Builder metadata propagates into index
1064    #[test]
1065    fn builder_metadata_in_index() {
1066        let mut builder = AssetPackBuilder::new("meta-pack");
1067        builder
1068            .set_author("Alice")
1069            .set_version("2.0.0")
1070            .set_license("MIT")
1071            .set_description("A test pack");
1072        let bytes = builder.build().expect("should succeed");
1073        let index = load_pack_from_bytes(&bytes).expect("should succeed");
1074        assert_eq!(index.author, "Alice");
1075        assert_eq!(index.version, "2.0.0");
1076        assert_eq!(index.license, "MIT");
1077        assert_eq!(index.description, "A test pack");
1078    }
1079
1080    // 25. Corrupted bytes rejected by load_pack_from_bytes
1081    #[test]
1082    fn corrupted_bytes_rejected() {
1083        let mut builder = AssetPackBuilder::new("pack");
1084        builder
1085            .add_preset(make_simple_preset("P"))
1086            .expect("should succeed");
1087        let mut bytes = builder.build().expect("should succeed");
1088        // Flip a byte near the middle to corrupt the pack
1089        let mid = bytes.len() / 2;
1090        bytes[mid] ^= 0xFF;
1091        assert!(load_pack_from_bytes(&bytes).is_err());
1092    }
1093
1094    // 26. TextureFormat extension and mime_type helpers
1095    #[test]
1096    fn texture_format_helpers() {
1097        assert_eq!(TextureFormat::Png.extension(), "png");
1098        assert_eq!(TextureFormat::Jpeg.extension(), "jpg");
1099        assert_eq!(TextureFormat::Exr.extension(), "exr");
1100        assert_eq!(TextureFormat::Png.mime_type(), "image/png");
1101    }
1102
1103    // 27. Preset pack_path is unique per name
1104    #[test]
1105    fn preset_pack_paths_unique() {
1106        let p1 = make_simple_preset("Athletic Body");
1107        let p2 = make_simple_preset("Slim Body");
1108        assert_ne!(p1.pack_path(), p2.pack_path());
1109    }
1110
1111    // 28. Alpha pack Athletic preset has expected params
1112    #[test]
1113    fn alpha_pack_athletic_params() {
1114        let bytes = build_alpha_pack();
1115        let index = load_pack_from_bytes(&bytes).expect("should succeed");
1116        let athletic = index
1117            .presets
1118            .iter()
1119            .find(|p| p.name == "Athletic")
1120            .expect("Athletic preset not found");
1121        assert!(
1122            athletic.params.contains_key("muscle_mass"),
1123            "Athletic preset missing muscle_mass param"
1124        );
1125        let mm = athletic.params["muscle_mass"];
1126        assert!(mm > 0.5, "Athletic muscle_mass should be > 0.5, got {}", mm);
1127    }
1128
1129    // 29. Alpha pack Metal material is highly metallic
1130    #[test]
1131    fn alpha_pack_metal_material_metallic() {
1132        let bytes = build_alpha_pack();
1133        let index = load_pack_from_bytes(&bytes).expect("should succeed");
1134        let metal = index
1135            .materials
1136            .iter()
1137            .find(|m| m.name == "Metal")
1138            .expect("Metal material not found");
1139        assert!(
1140            metal.metallic > 0.9,
1141            "Metal material should have metallic > 0.9, got {}",
1142            metal.metallic
1143        );
1144    }
1145
1146    // 30. AssetPackMeta default values
1147    #[test]
1148    fn asset_pack_meta_defaults() {
1149        let meta = AssetPackMeta::default();
1150        assert_eq!(meta.version, "0.1.0");
1151        assert_eq!(meta.license, "Apache-2.0");
1152        assert_eq!(meta.created_at, 0);
1153    }
1154
1155    // 31–35. Distribution manifest tests
1156
1157    #[test]
1158    fn test_generate_and_verify_manifest() {
1159        let dir = std::env::temp_dir().join("oxihuman_dist_test_basic");
1160        std::fs::create_dir_all(&dir).expect("create dir");
1161        std::fs::write(dir.join("test.bin"), b"hello world").expect("write test.bin");
1162
1163        let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
1164        assert!(
1165            manifest.contains("test.bin"),
1166            "manifest should reference test.bin"
1167        );
1168        assert!(
1169            manifest.contains("schema_version"),
1170            "manifest should have schema_version"
1171        );
1172
1173        let ok = verify_distribution_manifest(&manifest, &dir).expect("verify manifest");
1174        assert!(ok, "fresh manifest must verify successfully");
1175
1176        std::fs::remove_dir_all(&dir).ok();
1177    }
1178
1179    #[test]
1180    fn test_manifest_detects_tampered_file() {
1181        let dir = std::env::temp_dir().join("oxihuman_dist_test_tamper");
1182        std::fs::create_dir_all(&dir).expect("create dir");
1183        std::fs::write(dir.join("data.bin"), b"original").expect("write data.bin");
1184
1185        let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
1186
1187        // Tamper with the file contents
1188        std::fs::write(dir.join("data.bin"), b"tampered!").expect("overwrite data.bin");
1189
1190        let ok = verify_distribution_manifest(&manifest, &dir).expect("verify call should not err");
1191        assert!(!ok, "tampered file must cause verification failure");
1192
1193        std::fs::remove_dir_all(&dir).ok();
1194    }
1195
1196    #[test]
1197    fn test_manifest_detects_missing_file() {
1198        let dir = std::env::temp_dir().join("oxihuman_dist_test_missing");
1199        std::fs::create_dir_all(&dir).expect("create dir");
1200        std::fs::write(dir.join("present.bin"), b"data").expect("write present.bin");
1201
1202        let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
1203
1204        // Delete the file to simulate a missing-file scenario
1205        std::fs::remove_file(dir.join("present.bin")).ok();
1206
1207        let ok = verify_distribution_manifest(&manifest, &dir).expect("verify call should not err");
1208        assert!(!ok, "missing file must cause verification failure");
1209
1210        std::fs::remove_dir_all(&dir).ok();
1211    }
1212
1213    #[test]
1214    fn test_manifest_schema_version() {
1215        let dir = std::env::temp_dir().join("oxihuman_dist_test_schema");
1216        std::fs::create_dir_all(&dir).expect("create dir");
1217        std::fs::write(dir.join("x.bin"), b"x").expect("write x.bin");
1218
1219        let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
1220        let v: serde_json::Value =
1221            serde_json::from_str(&manifest).expect("manifest must be valid JSON");
1222        assert_eq!(
1223            v["schema_version"]
1224                .as_str()
1225                .expect("schema_version must be string"),
1226            "0.1.1"
1227        );
1228
1229        std::fs::remove_dir_all(&dir).ok();
1230    }
1231
1232    #[test]
1233    fn test_manifest_multiple_files() {
1234        let dir = std::env::temp_dir().join("oxihuman_dist_test_multi");
1235        std::fs::create_dir_all(&dir).expect("create dir");
1236        std::fs::write(dir.join("a.bin"), b"alpha").expect("write a.bin");
1237        std::fs::write(dir.join("b.bin"), b"beta").expect("write b.bin");
1238        std::fs::write(dir.join("c.bin"), b"gamma").expect("write c.bin");
1239
1240        let manifest = generate_distribution_manifest(&dir).expect("generate manifest");
1241        let v: serde_json::Value =
1242            serde_json::from_str(&manifest).expect("manifest must be valid JSON");
1243        let files = v["files"].as_object().expect("files must be object");
1244        assert_eq!(files.len(), 3, "should have exactly 3 entries");
1245
1246        let ok = verify_distribution_manifest(&manifest, &dir).expect("verify");
1247        assert!(ok);
1248
1249        std::fs::remove_dir_all(&dir).ok();
1250    }
1251}