Compare commits

...

3 Commits

Author SHA1 Message Date
7e99243a78 Record deterministic asset acceptance
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
2026-07-13 12:09:19 -04:00
cbd380a941 Stabilize asset registry publication order 2026-07-13 11:46:51 -04:00
e1b2f86a49 Make imported asset fingerprints deterministic 2026-07-13 11:23:06 -04:00
32 changed files with 1454 additions and 637 deletions

View File

@ -0,0 +1,38 @@
# Deterministic Imported-Asset Fingerprints
**Status:** Complete at `cbd380a9413411667a83f74b66c5a2feda5e1af7`; see
[`docs/editor/evaluations/deterministic-asset-fingerprints/`](../../docs/editor/evaluations/deterministic-asset-fingerprints/).
## Scope
Implement Gitea #56 so imported-source identity is content-addressed and editor/validator runs do
not rewrite project artifacts because checkout timestamps or serialization whitespace changed.
## Contract
- Persist one shared fingerprint for imported model, texture, and audio sources: byte length plus a
BLAKE3 digest of the exact source bytes.
- Do not persist or compare source mtimes as semantic identity. Legacy timestamp fields remain
readable but are ignored.
- Static-mesh and animation manifests use the same content identity as the asset registry.
- Preserve stable registry UUIDs across metadata-only drift and uniquely identifiable source moves.
- Before writing a registry or generated manifest, compare the parsed semantic document; preserve
the existing bytes when the result is equivalent.
- Project validators stay read-only and validate both length and digest, including same-size edits.
## Implementation
1. Add the shared fingerprint type and migrate animation/static-mesh contracts.
2. Persist fingerprints for model, texture, and audio registry records and generalize move recovery.
3. Make registry and manifest publication semantic and byte-preserving.
4. Extend static-mesh and registry validation plus focused migration/read-only tests.
5. Update ADRs, asset-pipeline docs, CI clean-worktree assertion, and committed generated artifacts.
## Verification
- Focused editor and scene tests for mtime-only drift, same-size changes, legacy manifests,
byte-preserving equivalent refresh, imported source kinds, and read-only validation.
- Formatting, diff check, clippy, and workspace all-target tests.
- Native editor startup and asset refresh with before/after Git status and source timestamp drift.
- `cargo validate-levels --project .` and `cargo validate-samples --project .`, followed by a clean
worktree assertion. Packaged-runtime tests remain deferred by owner direction.

View File

@ -63,6 +63,11 @@ jobs:
- name: Validate editor sample pack
run: cargo validate-samples
- name: Assert validation leaves checkout clean
run: |
git diff --exit-code
test -z "$(git ls-files --others --exclude-standard)"
- name: Clippy launch feature matrix
run: cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings

View File

@ -377,6 +377,7 @@ The `.vscode/` folder is preconfigured:
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
- [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
- [ADR 0043: Content-Addressed Import Fingerprints](docs/adr/0043-content-addressed-import-fingerprints.md)
## Project Layout
@ -428,6 +429,9 @@ crates/
- [x] Production operator invariants across palette dispatch, assets/material drops, brush/terrain/physics modal tools, grouping/lighting, and the transform finalizer: stable terminal status, exact cancel/failure rollback, helper cleanup, grouped history, and repeated undo/redo projections ([testing contract](docs/editor/operator-regression-testing.md), [evaluation](docs/editor/evaluations/operator-invariants/), [Gitea #33](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/33))
- [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim`
- [x] Stable asset registry with UUIDs + import settings in asset browser details
- [x] Deterministic imported-source fingerprints for models, textures, and audio; BLAKE3-backed
static/animation manifests; byte-preserving equivalent refresh; and read-only validator checkout
assertions ([ADR 0043](docs/adr/0043-content-addressed-import-fingerprints.md), [evaluation](docs/editor/evaluations/deterministic-asset-fingerprints/), [Gitea #56](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/56))
- [x] Asset Browser expandable model subasset shelves, independent mesh/material/texture thumbnails, staged import/material details with shader-schema parameters, context actions, and trash-first file removal
- [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
(
schema_version: 3,
schema_version: 4,
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
label: "RobotExpressive",
source: (
@ -7,7 +7,7 @@
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783799272,
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
),
dependencies: [],
),

View File

@ -1,5 +1,5 @@
(
schema_version: 3,
schema_version: 4,
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
label: "robot_expressive",
source: (
@ -7,7 +7,7 @@
format: "glb",
fingerprint: (
byte_len: 463988,
modified_unix_secs: 1783750957,
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
),
dependencies: [],
),

View File

@ -1,5 +1,5 @@
(
schema_version: 3,
schema_version: 4,
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
label: "painted_wooden_chair_02_2k",
source: (
@ -7,7 +7,7 @@
format: "fbx",
fingerprint: (
byte_len: 59964,
modified_unix_secs: 1780713434,
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
),
dependencies: [],
),

View File

@ -3,7 +3,6 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use serde::Deserialize;
use shared::{
@ -14,6 +13,7 @@ use shared::{
};
use crate::asset_db::AssetRecord;
use crate::assets::fingerprint::write_pretty_ron_if_changed;
pub fn animation_manifest_path(asset_id: &str) -> String {
format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron")
@ -32,15 +32,9 @@ pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result<AnimationM
record.dependencies.sort();
record.dependencies.dedup();
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize animation manifest: {error}"))?;
if let Some(parent) = Path::new(&path).parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
}
if fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
fs::write(&path, text)
.map_err(|error| format!("could not write animation manifest {path}: {error}"))?;
if write_pretty_ron_if_changed(&path, &manifest)
.map_err(|error| format!("could not publish animation manifest {path}: {error}"))?
{
bevy::log::info!(
"Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}",
record.path,
@ -63,7 +57,7 @@ pub fn load_animation_manifest(path: &str) -> Result<AnimationManifest, String>
pub fn build_animation_manifest(record: &AssetRecord) -> Result<AnimationManifest, String> {
let bytes = fs::read(&record.path)
.map_err(|error| format!("could not read {}: {error}", record.path))?;
let fingerprint = source_fingerprint(&record.path, &bytes)?;
let fingerprint = source_fingerprint(&bytes);
let format = source_format(&record.path)?;
match format.as_str() {
"gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes),
@ -582,19 +576,8 @@ fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec<u8> {
.collect()
}
fn source_fingerprint(path: &str, bytes: &[u8]) -> Result<AnimationSourceFingerprint, String> {
let metadata = fs::metadata(path).map_err(|error| format!("could not stat {path}: {error}"))?;
let modified_unix_secs = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
Ok(AnimationSourceFingerprint {
byte_len: metadata.len(),
modified_unix_secs,
content_hash: blake3::hash(bytes).to_hex().to_string(),
})
fn source_fingerprint(bytes: &[u8]) -> AnimationSourceFingerprint {
AnimationSourceFingerprint::from_bytes(bytes)
}
fn source_format(path: &str) -> Result<String, String> {
@ -626,6 +609,7 @@ mod tests {
path: path.to_string_lossy().replace('\\', "/"),
label: "Animated Hero".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
}
@ -754,6 +738,30 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_mtime_metadata_does_not_rewrite_equivalent_animation_manifest() {
let root =
std::env::temp_dir().join(format!("blacksite-animation-legacy-{}", Uuid::new_v4()));
let path = write_animated_gltf(&root);
let manifest = build_animation_manifest(&fixture_record(&path)).unwrap();
let canonical =
ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap();
let marker = format!("byte_len: {},", manifest.source.fingerprint.byte_len);
let legacy = canonical.replacen(
&marker,
&format!("{marker}\n modified_unix_secs: 123456,"),
1,
);
assert_ne!(legacy, canonical);
let artifact = root.join("legacy.animation.ron");
fs::write(&artifact, &legacy).unwrap();
assert!(!write_pretty_ron_if_changed(&artifact, &manifest).unwrap());
assert_eq!(fs::read_to_string(&artifact).unwrap(), legacy);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() {
let root =

View File

@ -2,9 +2,12 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use shared::AssetSourceFingerprint;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed};
/// Stable asset identity for dependency tracking and prefab references.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct AssetId(pub Uuid);
@ -93,6 +96,8 @@ pub struct AssetRecord {
pub path: String,
pub label: String,
pub kind_tag: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_fingerprint: Option<AssetSourceFingerprint>,
#[serde(default)]
pub import_settings: ImportSettings,
#[serde(default)]
@ -122,9 +127,10 @@ fn sync_registry_from_browser(
return;
}
let mut existing: HashMap<String, AssetRecord> = registry
.records
.drain(..)
let previous_records = std::mem::take(&mut registry.records);
let mut existing: HashMap<String, AssetRecord> = previous_records
.iter()
.cloned()
.map(|record| (record.path.clone(), record))
.collect();
let current_paths = assets
@ -146,12 +152,30 @@ fn sync_registry_from_browser(
seen_paths.insert(path.clone(), ());
let kind_tag = format!("{:?}", asset.kind);
let source_fingerprint = if imported_source_kind(&kind_tag) {
match fingerprint_file(&path) {
Ok(fingerprint) => Some(fingerprint),
Err(error) => {
warn!("Imported source fingerprint failed for {path}: {error}");
None
}
}
} else {
None
};
let mut record = if let Some(mut prior) = existing.remove(&path).or_else(|| {
take_uniquely_moved_model_record(&mut existing, &current_paths, &path, &kind_tag)
source_fingerprint.as_ref().and_then(|fingerprint| {
take_uniquely_moved_import_record(
&mut existing,
&current_paths,
&kind_tag,
fingerprint,
)
})
}) {
if prior.path != path {
info!(
"Asset registry preserved model identity {} across move {} -> {}",
"Asset registry preserved imported source identity {} across move {} -> {}",
prior.id.as_string(),
prior.path,
path
@ -159,18 +183,26 @@ fn sync_registry_from_browser(
prior.path = path.clone();
}
prior.label = asset.label.clone();
prior.kind_tag = kind_tag;
prior.kind_tag = kind_tag.clone();
prior
} else {
AssetRecord {
id: AssetId::new(),
path: path.clone(),
label: asset.label.clone(),
kind_tag,
kind_tag: kind_tag.clone(),
source_fingerprint: None,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
}
};
if imported_source_kind(&kind_tag) {
if let Some(source_fingerprint) = source_fingerprint {
record.source_fingerprint = Some(source_fingerprint);
}
} else {
record.source_fingerprint = None;
}
if record.kind_tag == "Model" {
if let Err(error) = super::refresh_model_artifacts(&mut record) {
warn!(
@ -183,7 +215,8 @@ fn sync_registry_from_browser(
next_records.push(record);
}
let changed = registry.records != next_records;
sort_registry_records(&mut next_records);
let changed = previous_records != next_records;
registry.records = next_records;
if changed {
registry.index_dirty = true;
@ -198,26 +231,32 @@ fn sync_registry_from_browser(
}
}
fn take_uniquely_moved_model_record(
fn imported_source_kind(kind_tag: &str) -> bool {
matches!(kind_tag, "Model" | "Texture" | "AudioClip")
}
fn sort_registry_records(records: &mut [AssetRecord]) {
records.sort_by(|left, right| left.path.cmp(&right.path));
}
fn take_uniquely_moved_import_record(
existing: &mut HashMap<String, AssetRecord>,
current_paths: &HashSet<String>,
new_path: &str,
kind_tag: &str,
fingerprint: &AssetSourceFingerprint,
) -> Option<AssetRecord> {
if kind_tag != "Model" {
if !imported_source_kind(kind_tag) {
return None;
}
let bytes = std::fs::read(new_path).ok()?;
let content_hash = blake3::hash(&bytes).to_hex().to_string();
let candidates = existing
.iter()
.filter_map(|(old_path, record)| {
if current_paths.contains(old_path) || record.kind_tag != "Model" {
if current_paths.contains(old_path) || record.kind_tag != kind_tag {
return None;
}
let manifest_path = record.import_settings.animation_manifest_path.as_deref()?;
let manifest = super::animation::load_animation_manifest(manifest_path).ok()?;
(manifest.source.fingerprint.content_hash == content_hash).then(|| old_path.clone())
recorded_source_fingerprint(record)
.is_some_and(|prior| prior == *fingerprint)
.then(|| old_path.clone())
})
.collect::<Vec<_>>();
if candidates.len() != 1 {
@ -226,6 +265,15 @@ fn take_uniquely_moved_model_record(
existing.remove(&candidates[0])
}
fn recorded_source_fingerprint(record: &AssetRecord) -> Option<AssetSourceFingerprint> {
record.source_fingerprint.clone().or_else(|| {
let manifest_path = record.import_settings.animation_manifest_path.as_deref()?;
super::animation::load_animation_manifest(manifest_path)
.ok()
.map(|manifest| manifest.source.fingerprint)
})
}
pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option<AssetRecord> {
registry
.records
@ -262,11 +310,16 @@ pub fn ensure_asset_record(
if let Some(record) = find_asset_by_path(registry, &path) {
return Ok(record);
}
let kind_tag = kind_tag.into();
let source_fingerprint = imported_source_kind(&kind_tag)
.then(|| fingerprint_file(&path).ok())
.flatten();
let record = AssetRecord {
id: AssetId::new(),
path,
label: label.into(),
kind_tag: kind_tag.into(),
kind_tag,
source_fingerprint,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};
@ -292,6 +345,9 @@ pub fn update_import_settings(
else {
return false;
};
if record.import_settings == settings {
return false;
}
record.import_settings = settings;
registry.index_dirty = true;
true
@ -302,13 +358,7 @@ pub fn registry_index_path() -> &'static str {
}
pub fn save_registry(registry: &AssetRegistry) -> Result<(), String> {
let path = registry_index_path();
if let Some(parent) = std::path::Path::new(path).parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let text = ron::ser::to_string_pretty(&registry.records, ron::ser::PrettyConfig::default())
.map_err(|e| e.to_string())?;
std::fs::write(path, text).map_err(|e| e.to_string())
write_pretty_ron_if_changed(registry_index_path(), &registry.records).map(|_| ())
}
pub fn load_registry() -> AssetRegistry {
@ -333,6 +383,101 @@ mod tests {
ANIMATION_MANIFEST_SCHEMA_VERSION,
};
#[test]
fn imported_source_policy_covers_model_texture_and_audio_only() {
for kind in ["Model", "Texture", "AudioClip"] {
assert!(imported_source_kind(kind), "missing imported kind {kind}");
}
for kind in ["Material", "Level", "Prefab", "PostProcessEffect"] {
assert!(
!imported_source_kind(kind),
"authored kind {kind} was hashed"
);
}
}
#[test]
fn registry_publication_order_is_stable_across_discovery_orders() {
let records = [
("assets/textures/z.png", "Texture"),
("assets/audio/a.ogg", "AudioClip"),
("assets/models/m.glb", "Model"),
]
.into_iter()
.map(|(path, kind_tag)| AssetRecord {
id: AssetId::new(),
path: path.into(),
label: path.into(),
kind_tag: kind_tag.into(),
source_fingerprint: None,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
})
.collect::<Vec<_>>();
let mut forward = records.clone();
let mut reverse = records.into_iter().rev().collect::<Vec<_>>();
sort_registry_records(&mut forward);
sort_registry_records(&mut reverse);
assert_eq!(forward, reverse);
assert_eq!(
forward
.iter()
.map(|record| record.path.as_str())
.collect::<Vec<_>>(),
[
"assets/audio/a.ogg",
"assets/models/m.glb",
"assets/textures/z.png",
]
);
}
#[test]
fn legacy_registry_record_loads_without_forcing_a_new_identity() {
let source = r#"(
id: ("00000000-0000-0000-0000-000000000000"),
path: "assets/textures/legacy.png",
label: "Legacy",
kind_tag: "Texture",
)"#;
let record: AssetRecord = ron::from_str(source).unwrap();
assert_eq!(record.id, AssetId(Uuid::nil()));
assert!(record.source_fingerprint.is_none());
assert_eq!(record.import_settings, ImportSettings::default());
}
#[test]
fn content_match_preserves_every_imported_source_kind_across_move() {
let fingerprint = AssetSourceFingerprint::from_bytes(b"stable imported bytes");
for kind in ["Model", "Texture", "AudioClip"] {
let record = AssetRecord {
id: AssetId::new(),
path: format!("assets/old/{kind}"),
label: kind.into(),
kind_tag: kind.into(),
source_fingerprint: Some(fingerprint.clone()),
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};
let expected_id = record.id.clone();
let mut existing = HashMap::from([(record.path.clone(), record)]);
let moved = take_uniquely_moved_import_record(
&mut existing,
&HashSet::new(),
kind,
&fingerprint,
)
.expect("unique content identity should preserve the record");
assert_eq!(moved.id, expected_id);
assert!(existing.is_empty());
}
}
fn moved_model_fixture(bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf, AssetRecord) {
let key = Uuid::new_v4();
let source_path = std::env::temp_dir().join(format!("blacksite-moved-model-{key}.glb"));
@ -351,7 +496,6 @@ mod tests {
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: bytes.len() as u64,
modified_unix_secs: 0,
content_hash: blake3::hash(bytes).to_hex().to_string(),
},
dependencies: Vec::new(),
@ -371,6 +515,7 @@ mod tests {
path: old_path,
label: "Moved Model".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: ImportSettings {
animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()),
..Default::default()
@ -385,12 +530,13 @@ mod tests {
let (source_path, manifest_path, record) = moved_model_fixture(b"stable model bytes");
let expected_id = record.id.clone();
let mut existing = HashMap::from([(record.path.clone(), record)]);
let fingerprint = fingerprint_file(&source_path).unwrap();
let moved = take_uniquely_moved_model_record(
let moved = take_uniquely_moved_import_record(
&mut existing,
&HashSet::new(),
&source_path.to_string_lossy(),
"Model",
&fingerprint,
)
.expect("unique moved model should retain its registry record");
@ -406,13 +552,10 @@ mod tests {
let old_path = record.path.clone();
let mut existing = HashMap::from([(old_path.clone(), record)]);
let current_paths = HashSet::from([old_path]);
let fingerprint = fingerprint_file(&source_path).unwrap();
let moved = take_uniquely_moved_model_record(
&mut existing,
&current_paths,
&source_path.to_string_lossy(),
"Model",
);
let moved =
take_uniquely_moved_import_record(&mut existing, &current_paths, "Model", &fingerprint);
assert!(moved.is_none());
assert_eq!(existing.len(), 1);

View File

@ -1548,6 +1548,7 @@ mod tests {
path: asset.path.clone().unwrap(),
label: asset.label.clone(),
kind_tag: "AudioClip".into(),
source_fingerprint: None,
import_settings: Default::default(),
dependencies: Vec::new(),
}],
@ -1630,7 +1631,6 @@ mod tests {
format: "glb".into(),
fingerprint: shared::AnimationSourceFingerprint {
byte_len: 1,
modified_unix_secs: 0,
content_hash: "fixture".into(),
},
dependencies: Vec::new(),
@ -1663,6 +1663,7 @@ mod tests {
path: "assets/models/robot.glb".into(),
label: "Robot".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: crate::asset_db::ImportSettings {
animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()),
default_animation_clip_id: Some(clip_id.clone()),

View File

@ -0,0 +1,114 @@
use std::fs;
use std::path::Path;
use serde::de::DeserializeOwned;
use serde::Serialize;
use shared::AssetSourceFingerprint;
pub(crate) fn fingerprint_file(path: impl AsRef<Path>) -> Result<AssetSourceFingerprint, String> {
let path = path.as_ref();
let bytes = fs::read(path)
.map_err(|error| format!("could not read imported source {}: {error}", path.display()))?;
Ok(AssetSourceFingerprint::from_bytes(&bytes))
}
/// Writes canonical pretty RON only when the parsed document changes semantically.
///
/// Equivalent existing bytes, including custom formatting and final-newline policy, stay intact.
pub(crate) fn write_pretty_ron_if_changed<T>(
path: impl AsRef<Path>,
value: &T,
) -> Result<bool, String>
where
T: DeserializeOwned + PartialEq + Serialize,
{
let path = path.as_ref();
if fs::read_to_string(path)
.ok()
.and_then(|text| ron::from_str::<T>(&text).ok())
.is_some_and(|existing| existing == *value)
{
return Ok(false);
}
let text = ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default())
.map_err(|error| format!("could not serialize RON: {error}"))?;
if fs::read(path).ok().as_deref() == Some(text.as_bytes()) {
return Ok(false);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
}
fs::write(path, text)
.map_err(|error| format!("could not write {}: {error}", path.display()))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use std::fs::{File, FileTimes};
use std::time::{Duration, SystemTime};
use uuid::Uuid;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
struct Fixture {
count: u32,
label: String,
}
fn fixture_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"blacksite-fingerprint-{name}-{}.ron",
Uuid::new_v4()
))
}
#[test]
fn metadata_only_drift_does_not_change_content_identity() {
let path = fixture_path("mtime");
fs::write(&path, b"stable source bytes").unwrap();
let before = fingerprint_file(&path).unwrap();
File::options()
.write(true)
.open(&path)
.unwrap()
.set_times(
FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(86_400)),
)
.unwrap();
assert_eq!(fingerprint_file(&path).unwrap(), before);
fs::remove_file(path).unwrap();
}
#[test]
fn same_size_byte_change_updates_content_identity() {
let path = fixture_path("same-size");
fs::write(&path, b"source-a").unwrap();
let before = fingerprint_file(&path).unwrap();
fs::write(&path, b"source-b").unwrap();
let after = fingerprint_file(&path).unwrap();
assert_eq!(before.byte_len, after.byte_len);
assert_ne!(before.content_hash, after.content_hash);
fs::remove_file(path).unwrap();
}
#[test]
fn equivalent_ron_preserves_exact_existing_bytes() {
let path = fixture_path("semantic");
let existing = b"( label: \"stable\", count: 7, )\n\n";
fs::write(&path, existing).unwrap();
let value = Fixture {
count: 7,
label: "stable".into(),
};
assert!(!write_pretty_ron_if_changed(&path, &value).unwrap());
assert_eq!(fs::read(&path).unwrap(), existing);
fs::remove_file(path).unwrap();
}
}

View File

@ -3,6 +3,7 @@
pub mod animation;
pub mod asset_db;
mod catalog;
mod fingerprint;
mod import;
pub mod materials;
pub mod operators;

View File

@ -228,7 +228,6 @@ mod tests {
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: 1,
modified_unix_secs: 0,
content_hash: "fixture".into(),
},
dependencies: Vec::new(),
@ -265,6 +264,7 @@ mod tests {
path: model_path.clone(),
label: "Operator Fixture".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: ImportSettings {
animation_manifest_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
@ -352,6 +352,7 @@ mod tests {
path: path.clone(),
label: "Impact".into(),
kind_tag: "AudioClip".into(),
source_fingerprint: None,
import_settings: Default::default(),
dependencies: Vec::new(),
}],
@ -502,6 +503,7 @@ mod tests {
path: material_path.clone(),
label: "Concrete".into(),
kind_tag: "Material".into(),
source_fingerprint: None,
import_settings: Default::default(),
dependencies: Vec::new(),
}],

View File

@ -2,7 +2,6 @@
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use bevy::gltf::GltfAssetLabel;
use bevy::prelude::*;
@ -15,11 +14,13 @@ use crate::asset_db::{
AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
};
use shared::{
ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot,
StaticMeshRenderer, StaticMeshRendererEntry,
AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet,
RendererMaterialSlot, StaticMeshRenderer, StaticMeshRendererEntry,
};
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 3;
use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed};
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4;
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -42,11 +43,7 @@ pub struct StaticMeshSource {
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshSourceFingerprint {
pub byte_len: u64,
pub modified_unix_secs: u64,
}
pub type StaticMeshSourceFingerprint = AssetSourceFingerprint;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshImportSnapshot {
@ -134,16 +131,9 @@ pub fn refresh_static_mesh_artifact(
manifest.source.dependencies.dedup();
record.dependencies = manifest.source.dependencies.clone();
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.map_err(|err| format!("could not serialize static mesh manifest: {err}"))?;
if let Some(parent) = Path::new(&path).parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("could not create {}: {err}", parent.display()))?;
}
let should_write = fs::read_to_string(&path).ok().as_deref() != Some(text.as_str());
if should_write {
fs::write(&path, text)
.map_err(|err| format!("could not write static mesh manifest {path}: {err}"))?;
if write_pretty_ron_if_changed(&path, &manifest)
.map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))?
{
info!(
"Static mesh manifest refreshed: source={} artifact={} parts={}",
record.path,
@ -287,7 +277,7 @@ fn part_effective_material_id(part: &StaticMeshPart) -> Option<String> {
fn build_static_mesh_manifest(record: &AssetRecord) -> Result<StaticMeshManifest, String> {
let format = source_format(&record.path)?;
let fingerprint = source_fingerprint(&record.path)?;
let fingerprint = fingerprint_file(&record.path)?;
let import = StaticMeshImportSnapshot {
scale: record.import_settings.scale,
generate_collider: record.import_settings.generate_collider,
@ -631,20 +621,6 @@ fn source_format(path: &str) -> Result<String, String> {
.ok_or_else(|| format!("asset path `{path}` has no extension"))
}
fn source_fingerprint(path: &str) -> Result<StaticMeshSourceFingerprint, String> {
let metadata = fs::metadata(path).map_err(|err| format!("could not stat {path}: {err}"))?;
let modified_unix_secs = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
Ok(StaticMeshSourceFingerprint {
byte_len: metadata.len(),
modified_unix_secs,
})
}
fn resolve_dependency(source_path: &str, uri: &str) -> String {
if uri.starts_with("data:") || uri.contains("://") {
return uri.to_string();
@ -672,7 +648,7 @@ mod tests {
format: "glb".into(),
fingerprint: StaticMeshSourceFingerprint {
byte_len: 42,
modified_unix_secs: 10,
content_hash: "a".repeat(64),
},
dependencies: Vec::new(),
},
@ -741,6 +717,55 @@ mod tests {
);
}
#[test]
fn schema_v3_manifest_without_hash_migrates_to_content_fingerprint() {
let root = std::env::temp_dir().join(format!(
"blacksite-static-mesh-legacy-{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
let path = root.join("legacy.static_mesh.ron");
let expected = test_manifest();
let canonical =
ron::ser::to_string_pretty(&expected, ron::ser::PrettyConfig::default()).unwrap();
let legacy = canonical
.replacen("schema_version: 4", "schema_version: 3", 1)
.lines()
.filter(|line| !line.contains("content_hash:"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&path, legacy).unwrap();
assert!(write_pretty_ron_if_changed(&path, &expected).unwrap());
assert_eq!(
load_static_mesh_manifest(&path.to_string_lossy()).unwrap(),
expected
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn equivalent_static_manifest_preserves_existing_formatting_and_newline() {
let root = std::env::temp_dir().join(format!(
"blacksite-static-mesh-semantic-{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
let path = root.join("stable.static_mesh.ron");
let manifest = test_manifest();
let exact = format!(
"{}\n\n",
ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap()
);
fs::write(&path, &exact).unwrap();
assert!(!write_pretty_ron_if_changed(&path, &manifest).unwrap());
assert_eq!(fs::read_to_string(&path).unwrap(), exact);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn renderer_from_manifest_can_ignore_source_materials() {
let manifest = test_manifest();
@ -785,6 +810,7 @@ mod tests {
path,
label: "Robot Expressive".into(),
kind_tag: "Model".into(),
source_fingerprint: None,
import_settings: ImportSettings::default(),
dependencies: Vec::new(),
};

View File

@ -884,7 +884,6 @@ mod tests {
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: 42,
modified_unix_secs: 1,
content_hash: "hash".into(),
},
dependencies: Vec::new(),

View File

@ -1267,7 +1267,6 @@ mod tests {
format: "glb".into(),
fingerprint: AnimationSourceFingerprint {
byte_len: 1,
modified_unix_secs: 0,
content_hash: "fixture".into(),
},
dependencies: Vec::new(),

View File

@ -9,12 +9,13 @@ use shared::{
animation_clip_source_index, animation_clip_sub_asset_id, animation_skeleton_source_index,
animation_skeleton_sub_asset_id,
brush_math::{validate_brush, BrushDiagnosticSeverity},
AnimationControllerDesc, AnimationDiagnosticSeverity, AnimationManifest, AudioListenerDesc,
AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset,
MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset,
PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
SkinnedMeshRenderer, StaticMeshRenderer, TerrainDesc, ANIMATION_MANIFEST_SCHEMA_VERSION,
AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
AnimationControllerDesc, AnimationDiagnosticSeverity, AnimationManifest,
AssetSourceFingerprint, AudioListenerDesc, AudioSourceDesc, BrushDesc, ColliderDesc,
ColliderShapeDesc, EditorAssetRef, MaterialAsset, MaterialDesc, MaterialInstanceAsset,
MaterialOverride, ModelRef, PostProcessEffectAsset, PostProcessVolumeDesc, PrefabInstance,
PrefabRef, RendererMaterialSet, ShaderSchemaAsset, SkinnedMeshRenderer, StaticMeshRenderer,
TerrainDesc, ANIMATION_MANIFEST_SCHEMA_VERSION, AUDIO_CLIP_SUB_ASSET_ID,
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
};
@ -322,6 +323,8 @@ struct RegistryAssetRecord {
path: String,
kind_tag: String,
#[serde(default)]
source_fingerprint: Option<AssetSourceFingerprint>,
#[serde(default)]
import_settings: RegistryImportSettings,
#[serde(default)]
dependencies: Vec<String>,
@ -482,10 +485,96 @@ fn validate_asset_registry(
repair: "Convert the clip to Ogg Vorbis, WAV, MP3, or FLAC and reimport it.".into(),
});
}
if matches!(record.kind_tag.as_str(), "Model" | "Texture" | "AudioClip") {
validate_content_fingerprint(
project_root,
report,
"assets/.index/registry.ron",
&record.path,
record.source_fingerprint.as_ref(),
"registry.source_fingerprint_missing",
"registry.source_fingerprint_stale",
"registered imported source",
);
}
}
animation_catalog
}
#[allow(clippy::too_many_arguments)]
fn validate_content_fingerprint(
project_root: &Path,
report: &mut ProjectValidationReport,
owner_path: &str,
source_path: &str,
fingerprint: Option<&AssetSourceFingerprint>,
missing_code: &str,
stale_code: &str,
source_label: &str,
) {
let Some(fingerprint) = fingerprint.filter(|fingerprint| fingerprint.is_complete()) else {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: missing_code.into(),
source_path: owner_path.into(),
owner_actor_id: None,
reference: Some(source_path.into()),
message: format!("{source_label} has no complete BLAKE3 content fingerprint"),
repair: "Refresh the asset registry or reimport the source with this editor version."
.into(),
});
return;
};
let Some(path) = resolve_reference(project_root, source_path) else {
return;
};
let Ok(metadata) = std::fs::metadata(&path) else {
return;
};
if metadata.len() != fingerprint.byte_len {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: stale_code.into(),
source_path: owner_path.into(),
owner_actor_id: None,
reference: Some(source_path.into()),
message: format!(
"{source_label} size changed from {} to {} bytes",
fingerprint.byte_len,
metadata.len()
),
repair: "Refresh or reimport the changed source before validation or packaging.".into(),
});
return;
}
match blake3_file_hash(&path) {
Ok(content_hash) if content_hash != fingerprint.content_hash => {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: stale_code.into(),
source_path: owner_path.into(),
owner_actor_id: None,
reference: Some(source_path.into()),
message: format!(
"{source_label} content changed even though its byte length is unchanged"
),
repair: "Refresh or reimport the changed source before validation or packaging."
.into(),
});
}
Ok(_) => {}
Err(error) => report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: stale_code.into(),
source_path: owner_path.into(),
owner_actor_id: None,
reference: Some(source_path.into()),
message: format!("could not hash {source_label}: {error}"),
repair: "Restore readable source content and rerun validation.".into(),
}),
}
}
fn reference_is_runtime_package_asset(reference: &str) -> bool {
let relative = Path::new(reference.split('#').next().unwrap_or(reference));
if relative.is_absolute()
@ -709,10 +798,12 @@ struct StaticMeshManifestView {
struct StaticMeshSourceView {
path: String,
#[serde(default)]
fingerprint: AssetSourceFingerprint,
#[serde(default)]
dependencies: Vec<String>,
}
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 3;
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 4;
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
for path in ron_files(
@ -754,6 +845,16 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid
"model",
&manifest.source.path,
);
validate_content_fingerprint(
project_root,
report,
&source,
&manifest.source.path,
Some(&manifest.source.fingerprint),
"static_mesh.manifest_fingerprint_missing",
"static_mesh.manifest_stale",
"static mesh source",
);
for dependency in manifest.source.dependencies {
add_reference(
project_root,
@ -975,12 +1076,7 @@ fn validate_animation_manifest_contract(
}
let fingerprint = &manifest.source.fingerprint;
let hash_is_present = fingerprint.content_hash.len() == 64
&& fingerprint
.content_hash
.bytes()
.all(|byte| byte.is_ascii_hexdigit());
if fingerprint.byte_len == 0 || fingerprint.modified_unix_secs == 0 || !hash_is_present {
if fingerprint.byte_len == 0 || !fingerprint.is_complete() {
report.findings.push(ProjectValidationFinding {
severity: ValidationSeverity::Error,
code: "animation.manifest_fingerprint_missing".into(),
@ -3071,7 +3167,7 @@ mod tests {
}
#[test]
fn static_mesh_manifest_schema_v3_is_current() {
fn static_mesh_manifest_schema_v4_is_current() {
let root = fixture_root();
let source_path = "assets/models/static.glb";
std::fs::create_dir_all(root.join("assets/models")).unwrap();
@ -3081,7 +3177,8 @@ mod tests {
std::fs::write(
artifact_dir.join("static-id.static_mesh.ron"),
format!(
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",dependencies:[]),warnings:[])"
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",fingerprint:(byte_len:5,content_hash:\"{}\"),dependencies:[]),warnings:[])",
blake3::hash(b"model").to_hex()
),
)
.unwrap();
@ -3095,6 +3192,70 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn same_size_static_mesh_source_change_invalidates_content_hash() {
let root = fixture_root();
let source_path = "assets/models/static.glb";
std::fs::create_dir_all(root.join("assets/models")).unwrap();
std::fs::write(root.join(source_path), b"model").unwrap();
let fingerprint = AssetSourceFingerprint::from_bytes(b"model");
let artifact = root.join("assets/meshes/generated/static-id.static_mesh.ron");
std::fs::create_dir_all(artifact.parent().unwrap()).unwrap();
std::fs::write(
&artifact,
format!(
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",fingerprint:(byte_len:{},content_hash:\"{}\"),dependencies:[]),warnings:[])",
fingerprint.byte_len, fingerprint.content_hash
),
)
.unwrap();
std::fs::write(root.join(source_path), b"modem").unwrap();
let report = validate_project(&root);
let finding = report
.findings
.iter()
.find(|finding| finding.code == "static_mesh.manifest_stale")
.expect("same-size model edit must stale the static mesh manifest");
assert!(finding.message.contains("byte length is unchanged"));
assert!(finding.repair.contains("Refresh") || finding.repair.contains("reimport"));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn registry_detects_same_size_changes_for_all_imported_source_kinds() {
for (kind, relative) in [
("Model", "assets/models/source.glb"),
("Texture", "assets/textures/source.png"),
("AudioClip", "assets/audio/source.ogg"),
] {
let root = fixture_root();
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"source-a").unwrap();
let fingerprint = AssetSourceFingerprint::from_bytes(b"source-a");
std::fs::write(
root.join("assets/.index/registry.ron"),
format!(
"[(id:(\"00000000-0000-0000-0000-000000000001\"),path:\"{relative}\",kind_tag:\"{kind}\",source_fingerprint:Some((byte_len:{},content_hash:\"{}\")),dependencies:[])]",
fingerprint.byte_len, fingerprint.content_hash
),
)
.unwrap();
std::fs::write(&path, b"source-b").unwrap();
let report = validate_project(&root);
assert!(report.findings.iter().any(|finding| {
finding.code == "registry.source_fingerprint_stale"
&& finding.reference.as_deref() == Some(relative)
&& finding.message.contains("byte length is unchanged")
}));
std::fs::remove_dir_all(root).unwrap();
}
}
fn write_manifest(root: &Path, version: u32) {
std::fs::write(
root.join("assets/project.ron"),
@ -3128,7 +3289,6 @@ mod tests {
format: source_format.into(),
fingerprint: AnimationSourceFingerprint {
byte_len: 5,
modified_unix_secs: 1,
content_hash: blake3::hash(b"model").to_hex().to_string(),
},
dependencies: Vec::new(),
@ -3188,10 +3348,12 @@ mod tests {
let manifest = animation_manifest_path
.map(|path| format!("Some({path:?})"))
.unwrap_or_else(|| "None".into());
let fingerprint = AssetSourceFingerprint::from_bytes(b"model");
std::fs::write(
root.join("assets/.index/registry.ron"),
format!(
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:{manifest}),dependencies:[])]"
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",source_fingerprint:Some((byte_len:{},content_hash:\"{}\")),import_settings:(static_mesh_manifest_path:None,animation_manifest_path:{manifest}),dependencies:[])]",
fingerprint.byte_len, fingerprint.content_hash
),
)
.unwrap();
@ -3205,10 +3367,12 @@ mod tests {
let source = root.join(source_path);
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(source, b"model").unwrap();
let fingerprint = AssetSourceFingerprint::from_bytes(b"model");
std::fs::write(
root.join("assets/.index/registry.ron"),
format!(
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:Some(\"{ANIMATION_MANIFEST_PATH}\"),default_animation_clip_id:Some(\"{default_clip_id}\")),dependencies:[])]"
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",source_fingerprint:Some((byte_len:{},content_hash:\"{}\")),import_settings:(static_mesh_manifest_path:None,animation_manifest_path:Some(\"{ANIMATION_MANIFEST_PATH}\"),default_animation_clip_id:Some(\"{default_clip_id}\")),dependencies:[])]",
fingerprint.byte_len, fingerprint.content_hash
),
)
.unwrap();
@ -3632,6 +3796,49 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn project_validation_preserves_registry_and_generated_artifact_bytes() {
let root = fixture_root();
let source_path = "assets/models/hero.glb";
let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
write_animation_manifest(&root, &manifest);
write_animation_scene(&root, &animation_fixture_controller(&manifest));
let static_path = root.join("assets/meshes/generated/model-id.static_mesh.ron");
std::fs::create_dir_all(static_path.parent().unwrap()).unwrap();
std::fs::write(
&static_path,
format!(
"( schema_version: {STATIC_MESH_MANIFEST_SCHEMA_VERSION}, asset_id: \"model-id\", source: ( path: \"{source_path}\", fingerprint: ( byte_len: {}, content_hash: \"{}\", ), dependencies: [], ), warnings: [], )\n\n",
manifest.source.fingerprint.byte_len,
manifest.source.fingerprint.content_hash
),
)
.unwrap();
let paths = [
root.join("assets/.index/registry.ron"),
root.join(ANIMATION_MANIFEST_PATH),
static_path,
];
let before = paths
.iter()
.map(std::fs::read)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let _ = validate_project(&root);
let _ = validate_project(&root);
let after = paths
.iter()
.map(std::fs::read)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(after, before);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn missing_animation_manifest_is_reported_globally_and_on_the_actor_property() {
let root = fixture_root();

View File

@ -266,13 +266,8 @@ pub struct AnimationManifestSource {
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationSourceFingerprint {
pub byte_len: u64,
pub modified_unix_secs: u64,
/// BLAKE3 hash of the source file bytes.
pub content_hash: String,
}
/// Backward-compatible name for the shared imported-source fingerprint contract.
pub type AnimationSourceFingerprint = crate::AssetSourceFingerprint;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnimationSkeletonRecord {

View File

@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
/// Stable identity for the exact bytes of an imported project source.
///
/// Filesystem timestamps are deliberately excluded: Git and LFS do not preserve checkout mtimes,
/// so they cannot participate in a portable authored-data contract.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AssetSourceFingerprint {
pub byte_len: u64,
#[serde(default)]
pub content_hash: String,
}
impl AssetSourceFingerprint {
pub fn from_bytes(bytes: &[u8]) -> Self {
Self {
byte_len: bytes.len() as u64,
content_hash: blake3::hash(bytes).to_hex().to_string(),
}
}
pub fn is_complete(&self) -> bool {
self.content_hash.len() == 64
&& self
.content_hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_bytes_define_source_identity() {
let first = AssetSourceFingerprint::from_bytes(b"asset-a");
let same = AssetSourceFingerprint::from_bytes(b"asset-a");
let same_size_change = AssetSourceFingerprint::from_bytes(b"asset-b");
assert_eq!(first, same);
assert_ne!(first, same_size_change);
assert_eq!(first.byte_len, same_size_change.byte_len);
assert!(first.is_complete());
let mut noncanonical = first;
noncanonical.content_hash.make_ascii_uppercase();
assert!(!noncanonical.is_complete());
}
}

View File

@ -6,6 +6,7 @@
mod actor;
mod animation;
mod asset_fingerprint;
pub mod brush_math;
mod components;
mod hydration;
@ -18,6 +19,7 @@ mod rendering_profile_asset;
pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
pub use animation::*;
pub use asset_fingerprint::AssetSourceFingerprint;
pub use components::*;
pub use hydration::{
cascade_config_from_rendering, flush_level_object_hydration,

View File

@ -57,6 +57,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
| [0043](adr/0043-content-addressed-import-fingerprints.md) | Content-addressed imported-source identity and byte-preserving artifact publication |
## Editor framework
@ -97,6 +98,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/evaluations/sample-regression-pack/](editor/evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the editor sample regression pack |
| [editor/evaluations/guarded-shutdown-savepoints/](editor/evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
| [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
| [editor/evaluations/deterministic-asset-fingerprints/](editor/evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
## Working plans (not canonical long-term)

View File

@ -13,7 +13,12 @@ retaining explicit, separate paths for skinned geometry and generic full-scene d
## Decision
Model imports now generate deterministic project artifacts under `assets/meshes/generated/*.static_mesh.ron`. The artifact records stable part IDs, source path, format, source fingerprint, import settings, mesh/material subasset labels, source node/material metadata, dependency paths, and warnings for metadata that is not played by the static mesh path.
Model imports now generate deterministic project artifacts under
`assets/meshes/generated/*.static_mesh.ron`. The artifact records stable part IDs, source path,
format, the content-addressed source fingerprint owned by
[ADR 0043](0043-content-addressed-import-fingerprints.md), import settings, mesh/material subasset
labels, source node/material metadata, dependency paths, and warnings for metadata that is not
played by the static mesh path.
The saved scene authoring component is `StaticMeshRenderer`. Its slots reference imported mesh/material assets by `EditorAssetRef` (`asset_id` + `sub_asset_id`) rather than source file paths. Hydration resolves those refs through the generated artifact, spawns runtime child `Mesh3d` parts, and strips/regenerates them like other hydrated ECS. Built-in primitives remain `Primitive`, and explicit full-scene placement remains `ImportedModel + ModelRef`.
@ -35,4 +40,6 @@ Hierarchy mode is also stored per asset. `SingleActor` places all mesh parts int
- Static mesh actors can expose renderer slots, source labels, shadows, collider policy, and material overrides in the inspector.
- Skinned parts are marked in artifact schema v2 and excluded from static renderer construction and
hydration. Animation playback uses the dedicated contract in ADRs 0031 and 0033.
- Static-mesh schema v4 uses byte length plus BLAKE3 rather than checkout mtime, so a source touch is
a no-op while a same-size byte edit is detected.
- Generated artifacts are project files but are hidden from the Asset Browser catalog to avoid treating them as levels/prefabs.

View File

@ -42,6 +42,12 @@ startup entrypoint. Unknown newer scene schemas are rejected rather than down-st
requirements are explicit findings; for example, Solari projects record the required forward-path
fallback QA when ray tracing is unavailable.
Imported model, texture, and audio registry records, animation manifests, and static-mesh manifests
share the content-addressed fingerprint contract in
[ADR 0043](0043-content-addressed-import-fingerprints.md). Validation hashes source bytes and catches
same-size changes without consulting mtimes. The validation entry point is read-only; CI asserts that
running both level and sample validation leaves the checkout unchanged.
Terrain descriptors participate in the same actor-owned dependency pass. Their authored dimensions,
height and weight grids, chunk settings, base material, and material layers are validated before
hydration so a sample or shipping scene cannot hide an invalid or unresolved terrain reference.
@ -58,3 +64,5 @@ hydration so a sample or shipping scene cannot hide an invalid or unresolved ter
editor UI and headless tooling consume the same scene ordering and validation result.
- CI must hydrate Git LFS before validation because a pointer file is intentionally treated as
missing runtime content.
- Validators report stale or incomplete import fingerprints with repair guidance but never rewrite
the registry or generated artifacts.

View File

@ -0,0 +1,57 @@
# ADR 0043: Content-Addressed Import Fingerprints
## Status
Accepted
## Context
Generated model artifacts and the project asset registry must be stable across contributors, CI,
Git worktrees, and LFS checkouts. Git does not preserve source-file modification times. Persisting a
checkout mtime as source identity therefore caused the editor to rewrite otherwise equivalent
animation and static-mesh manifests during startup. Static-mesh manifests also used only byte length
and mtime, so a same-size source edit could evade the persisted identity contract.
Formatting-only publication was a second source of noise. Re-serializing an equivalent RON
document could normalize its final newline or layout even when import semantics had not changed.
That made clean-checkout release evidence unreliable and obscured intentional asset edits.
## Decision
`shared::AssetSourceFingerprint` is the persisted imported-source identity. It contains the exact
source byte length and lowercase BLAKE3 digest. Filesystem timestamps are not serialized or compared
as semantic identity; an importer may use them only as an in-memory scan hint before confirming
content.
The asset registry stores this fingerprint for every imported model, texture, and audio source.
Content identity also drives unique move reconciliation, so metadata-only drift cannot replace a
stable registry UUID. Ambiguous copies still receive a new identity.
Animation and static-mesh manifests use the same fingerprint. Animation schema v3 already contained
a content hash, so its schema remains v3 and legacy `modified_unix_secs` fields are accepted as
ignored unknown metadata. Static-mesh schema v4 replaces its length-plus-mtime record with length
plus BLAKE3. Existing schema-v3 static artifacts are regenerated once; unrelated assets are not
reimported.
Registry and generated-manifest writers parse the existing RON and compare the semantic value before
publication. Equivalent documents preserve their exact existing bytes, including layout and final
newlines. Changed documents publish canonical pretty RON. Registry records are ordered by normalized
project path before comparison, so filesystem enumeration order cannot change their serialized
sequence on a fresh checkout.
Authoritative project validation remains read-only. It validates registry, animation, and
static-mesh fingerprints by length and digest, including same-size source edits. CI runs both
headless validators and then asserts that tracked and untracked checkout state is unchanged.
## Consequences
- A source touch or fresh checkout mtime cannot dirty the project.
- Same-size source edits invalidate the affected registry/manifests deterministically.
- Model, texture, and audio IDs survive uniquely identifiable moves by content.
- Initial migration adds fingerprints to imported registry records and upgrades static manifests to
schema v4. Legacy animation files can remain byte-identical indefinitely because their timestamp
field is ignored.
- Startup hashing reads imported source bytes. Future scan caches may use metadata to avoid work,
but they must verify content before persisting a changed identity.
- External glTF/FBX dependency paths remain explicit manifest dependencies; extending fingerprints
to a dependency graph requires a separate versioned contract.

View File

@ -44,6 +44,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [evaluations/sample-regression-pack/](evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the five-area editor regression pack |
| [evaluations/guarded-shutdown-savepoints/](evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
| [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
| [evaluations/deterministic-asset-fingerprints/](evaluations/deterministic-asset-fingerprints/) | Source, fresh-checkout, hash-stability, and native acceptance evidence for imported-source fingerprints |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
## Subsystems (code → doc)
@ -63,6 +64,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| `ui/navigation_inspector.rs` / `scene::navigation` / `game::navigation` | Navigation authoring, deterministic bake artifacts, viewport preview, and runtime path queries | navigation-authoring.md, ADR 0032 |
| `assets/materials.rs` / `ui/material_library.rs` / `viewport/material_drop.rs` / `shared::renderer_material` | Shared material discovery, docked catalog/usage UI, exact reversible surface drops, stable renderer slots, and draw binding | material-system.md, ADR 0035 |
| `blacksite_surface` / `game_hot::rendering::solari` / `third_party/bevy_solari` | Surface ABI packing, raster composition, Solari evaluator dispatch, and deformation eligibility | material-system.md, rendering.md, ADR 0036 |
| `assets/fingerprint.rs` / `shared::AssetSourceFingerprint` | Content-addressed imported-source identity and byte-preserving registry/manifest publication | ADR 0043, evaluations/deterministic-asset-fingerprints/ |
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
@ -91,6 +93,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping.
- **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera.
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. **Audio** accepts Bevy-supported Ogg/Vorbis and Speex (`.ogg`, `.oga`, `.spx`), WAV, MP3, and FLAC clips under `assets/audio/`, with a dedicated filter, waveform icon, format/file details, and stable registry-backed references that retain a runtime source path. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. Before thumbnail loading, glTF sources preflight local external buffers/images and show a stable non-retryable failure state when a dependency is missing instead of repeatedly invoking the asset loader.
- **Imported-source fingerprints** use exact byte length plus lowercase BLAKE3 for model, texture, and audio registry records and for generated static-mesh/animation manifests. Filesystem timestamps are scan hints only; equivalent refresh preserves the exact committed RON bytes and registry publication order is normalized by project path. See [ADR 0043](../adr/0043-content-addressed-import-fingerprints.md) and the [acceptance record](evaluations/deterministic-asset-fingerprints/).
- **Material Library** is a dockable bottom-panel catalog for cross-folder Material and direct-base Material Instance authoring. It provides search, type and scene-usage filters, grid/list thumbnails, dependency health, usage counts, creation, guarded details editing, and first-class drag sources. Viewport drops resolve an exact renderer slot, primitive, or brush face under the pointer, preview transiently, reject incompatible/read-only targets explicitly, restore on target change/cancel, and commit one typed undo step on release.
- **Static/skinned renderer split** — model drag/drop uses normalized artifacts under `assets/meshes/generated/`. Unrigged, non-animated sources create `ActorKind::StaticMesh + StaticMeshRenderer`; skin-bound or animated sources create `ActorKind::SkinnedMesh + SkinnedMeshRenderer` and preserve the imported hierarchy. Static slots never contain marked skinned primitives or geometry from animated sources. `SceneInstance` placement keeps `ImportedModel + ModelRef` for generic full-source scenes. See [ADR 0033](../adr/0033-dedicated-skinned-mesh-renderer.md).
- **Animation authoring** — glTF/GLB imports generate UUID-keyed rig/clip manifests and expandable skeleton/clip shelves. Clip drag creates a skinned actor or adds one exact-signature-compatible state to a selected skinned actor through typed history. `AnimationControllerDesc` exposes named states, ranges, loop/speed, default/crossfade, and non-dirty runtime-backed preview; project validation resolves the same stable refs and blocks animated/skinned FBX with conversion guidance. Production acceptance completed in Gitea #46. See [animation-authoring.md](animation-authoring.md) and [ADR 0031](../adr/0031-animation-authoring-runtime-contract.md).

View File

@ -68,10 +68,11 @@ serialize into authored scenes.
`SkinnedMeshRenderer`, skeleton references, and clip references carry the model registry UUID. Generated
artifacts are keyed by that UUID; source paths are cached resolution and repair hints. When one
model is moved or renamed on disk, catalog refresh reconciles it by its animation-manifest content
hash, preserves the UUID, and regenerates both manifests with the new source path. An identical copy
is not treated as a move while the original still exists. Ambiguous content matches intentionally
receive a new identity rather than silently binding the wrong asset.
model is moved or renamed on disk, catalog refresh reconciles it by the registry's shared
content-addressed source fingerprint, preserves the UUID, and regenerates both manifests with the
new source path. The same registry policy covers textures and audio clips. An identical copy is not
treated as a move while the original still exists. Ambiguous content matches intentionally receive
a new identity rather than silently binding the wrong asset.
Scene schema v3 migrates legacy animated actors that paired `ModelRef` with
`AnimationControllerDesc` to the dedicated skinned renderer. Generic `ImportedModel + ModelRef`
@ -83,6 +84,12 @@ used as identity. Animation-manifest schema v1 artifacts must be reimported to g
signatures; schema v3 adds the explicit default-clip ID. Schema v2 deserializes as Imported rest
pose and never receives an inferred default.
Source identity uses exact byte length plus BLAKE3 and never checkout mtime. Existing animation
schema-v3 files may retain a legacy `modified_unix_secs` field in their exact bytes; the current
reader ignores it, and an equivalent refresh preserves the whole file unchanged. Static-mesh schema
v4 uses the same content fingerprint. See
[ADR 0043](../adr/0043-content-addressed-import-fingerprints.md).
## Validation And Format Boundary
Interactive diagnostics and `cargo validate-levels` resolve registry UUIDs, generated manifests,

View File

@ -224,7 +224,11 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
- **Processing:** the asset registry generates normalized model manifests under
`assets/meshes/generated/`. The manifests store stable part IDs, glTF/FBX mesh/material subasset
labels, whether each part is skin-bound, source metadata, dependencies, and import settings.
These artifacts are hidden from the Asset Browser catalog.
Model, texture, and audio registry records use byte length plus BLAKE3 source identity; model
manifests use the same policy. Semantic RON comparison preserves exact existing bytes for an
equivalent refresh, so checkout mtimes and formatting do not dirty project content. See
[ADR 0043](../adr/0043-content-addressed-import-fingerprints.md). These artifacts are hidden from
the Asset Browser catalog.
- **Browser subassets:** model rows can expand into a content shelf backed by the generated
manifest. Unrigged mesh subassets place as independent `StaticMeshRenderer` actors. A skinned
subasset places its owning source through `SkinnedMeshRenderer`, retaining joints and inverse bind

View File

@ -14,6 +14,7 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
| Representative editor coverage can silently disappear | Done | Versioned five-area manifest, typed scene gate, `validate-samples`, and native protocol; see [sample pack](sample-regression-pack.md) |
| Dirty editor exit can bypass an explicit decision, or transient entity IDs define document cleanliness | Done | Guarded shutdown and canonical authored-content checkpoints pass source validation and all seven native workflows at source implementation `4b33f32`, including dirty untitled Save All through Save As. See the [evaluation](evaluations/guarded-shutdown-savepoints/). |
| Scoped UI callbacks depend on temporarily unavailable host state or lose direct selection precedence | Done | Explicit selection/tab requests, panel-owned state, captured viewport data, and full-slice reconciliation pass six scoped regressions, independent re-review, and native toolbar/Inspector/diagnostics acceptance at `9e23ae7`; see the [evaluation](evaluations/scoped-ui-actions/). |
| Checkout metadata or filesystem enumeration can rewrite imported-asset artifacts | Done | Shared content fingerprints, semantic byte-preserving publication, normalized registry order, clean-checkout validators, and live mtime-only drift pass at `cbd380a`; see the [evaluation](evaluations/deterministic-asset-fingerprints/). |
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |

View File

@ -0,0 +1,100 @@
# Deterministic Imported-Asset Fingerprints Evaluation
**Date:** 2026-07-13
**Issue:** [Gitea #56](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/56)
**Content-addressed implementation:** `e1b2f86a495496da83947777d332d00a15a1779d`
**Stable publication ordering:** `cbd380a9413411667a83f74b66c5a2feda5e1af7`
**Release-candidate commit:** Not nominated
**Source validation:** **Pass**
**Fresh-checkout acceptance:** **Pass**
**Native acceptance:** **Pass**
This record covers imported model, texture, and audio source identity plus the animation, static-mesh,
and registry documents derived from those sources. The permanent contract is owned by
[ADR 0043](../../../adr/0043-content-addressed-import-fingerprints.md).
## Automated Verification
| Command or suite | Result | Evidence |
|------------------|--------|----------|
| `cargo fmt --all -- --check` | Pass | Formatting completed without changes. |
| `git diff --check` | Pass | No whitespace errors. |
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Workspace and all targets completed with warnings denied. |
| `cargo test --workspace --all-targets --no-fail-fast` | Pass | All workspace targets passed; editor reported 300 passing tests, scene reported 100 passing and one ignored manual-migration test, and shared reported 99 passing tests. |
| Fingerprint regressions | Pass | Exact-byte BLAKE3 identity, same-size byte changes, mtime-only drift, legacy timestamp tolerance, stable move recovery, imported-source coverage, semantic RON equality, and read-only validation are covered. |
| `cargo validate-levels --project .` | Pass | 81 dependencies, five known nonblocking import/platform findings, zero blocking errors. |
| `cargo validate-samples --project .` | Pass | Five samples, 86 dependencies, the same five nonblocking findings, zero blocking errors. |
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
## Fresh Checkout
The first exact-commit run at `e1b2f86` exposed one remaining nondeterministic path: native startup
discovered catalog entries in filesystem order and rewrote `assets/.index/registry.ron`. That run
failed the clean-checkout gate. Commit `cbd380a` sorts records by normalized project path before
semantic comparison and adds a discovery-order regression test.
A new detached, LFS-hydrated worktree at `cbd380a` then completed native startup, asset scan,
`validate-levels`, and `validate-samples` with an empty `git status --short`. Touching
`assets/models/RobotExpressive.glb` while the editor was live also left the worktree clean. Its
original nanosecond timestamp was restored after the test.
The following SHA-256 values were identical before startup, after startup, after the mtime-only
change, and after both validators:
| Artifact | SHA-256 |
|----------|---------|
| `assets/.index/registry.ron` | `5beb11fb708729fb024e2e671596876bcf31b3c3fd2cea51da777db1d343644e` |
| `113f74df...static_mesh.ron` | `8d93e4d49c4f1d00ea8c482e8e1981f18d7a71224847c2b9f05f153982c5e80e` |
| `3f63f359...static_mesh.ron` | `e47099d59ec4ce9dc977ba6a08ddee36f1d99c0f13d9278998f21cba72571585` |
| `b98ef565...static_mesh.ron` | `edc77d61284c1010c8811d40bd7cedebfcbd6a3d88145e54d5c1ef7dc3a0a864` |
| `113f74df...animation.ron` | `0e678584aca470b36afd14b75a950d84a87d445438ba9b0a1e3416e863dda92a` |
| `3f63f359...animation.ron` | `45d3f9e9dcc64137f40a339c84512b6aba47102f7593aa6a9344891de68c0c07` |
| `b98ef565...animation.ron` | `691eb70ff05b2a4d03f51766f4c682a4171f7c76721f453c434f8de6fd936408` |
## Native Acceptance
| Workflow | Result | Evidence |
|----------|--------|----------|
| Main checkout startup | Pass | The exact debug editor at `e1b2f86` loaded the committed scene with populated hierarchy, viewport, Inspector, and Asset Browser, then exited with code 0. |
| Imported model browser | Pass | Opening `assets/models` rendered all three model rows and thumbnails; selecting the FBX exposed its registry-backed details without destabilizing the editor. |
| Fixed fresh checkout | Pass | The exact `cbd380a` editor launched from the detached worktree, rendered the complete editor frame, preserved every artifact byte, and exited with code 0. |
| Metadata-only drift | Pass | A live source `touch` produced no registry or manifest change and no Git diff. |
## Native Evidence
![Fresh LFS checkout running the complete editor without rewriting imported artifacts](https://git.spacetrainclubhouse.com/attachments/df761c85-0b82-4a25-9d33-9cf8189539f0)
- Gitea attachment asset 39: `blacksite-issue56-fresh-fixed-cbd380a.png`, 3426x1384 sRGB PNG,
1,787,700 bytes.
- SHA-256: `2748ae3de31ff97933c9793b90475ffd38d54d7e9dd5d65937b699f81c73c9bc`.
![Imported model thumbnails and registry-backed FBX details in the live editor](https://git.spacetrainclubhouse.com/attachments/93da7803-de47-43fb-bb8c-f759970a2b5f)
- Gitea attachment asset 40: `blacksite-issue56-models-e1b2f86.png`, 3426x1384 sRGB PNG,
1,940,210 bytes.
- SHA-256: `064c0634ebac47b52516412b022247ba8aa6f155014db8f8d3cca86850509da7`.
Both images are ordinary Gitea issue attachments. This record does not use repository raw or Git LFS
pointer URLs.
## Separate Finding
Selecting the painted-chair FBX exposed missing external texture dependencies in the committed
fixture: `painted_wooden_chair_02_diff_2k.jpg`, `painted_wooden_chair_02_nor_gl_2k.exr`, and
`painted_wooden_chair_02_rough_2k.exr`. The asset server reported six load errors because the paths
were requested more than once. This is an FBX dependency/import-fixture defect, not a deterministic
fingerprint failure, and is tracked separately in
[Gitea #58](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/58).
## Release Use
The #56 source, fresh-checkout, and native feature-level acceptance record is complete. A future
production candidate must rerun the clean-checkout protocol from the exact nominated commit. This
record does not nominate a candidate or establish production readiness.

View File

@ -1,6 +1,6 @@
# Production-Readiness Acceptance Matrix
**Matrix version:** 0.5
**Matrix version:** 0.6
**Last audit:** 2026-07-13
@ -30,21 +30,21 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| ID | Requirement | State | Current evidence and gap |
|----|-------------|-------|--------------------------|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, and scoped UI action safety `#57` have feature-level source/native acceptance. #55 passed all seven native paths at source implementation `4b33f32`; #57 passed toolbar, Inspector, diagnostics, selection, and guarded-exit paths at exact implementation `9e23ae7`. Candidate-specific end-to-end reruns remain. |
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, and deterministic asset fingerprints `#56` have feature-level source/native acceptance. #56 additionally passed a fresh LFS checkout, both validators, mtime-only drift, and artifact hash stability at `cbd380a`. Candidate-specific end-to-end reruns remain. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) passed source and native acceptance on exact implementation commit `d52cc2e`, including Brush, Material, Terrain, Physics Placement, and Rendering. No release candidate is nominated, brush acceptance `#37` remains open, and combined animation/audio/navigation/PIE/build evidence is incomplete. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS and includes both project and sample content gates. Local implementation checks have passed, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS, runs both project/sample content gates, and asserts a pristine checkout. The [#56 feature run](../deterministic-asset-fingerprints/) passed native startup and both validators from a fresh LFS-hydrated worktree at `cbd380a`, but no release candidate is nominated and no candidate CI run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, and the follow-on scoped UI action-safety defect `#57` have complete source/native feature acceptance at `d52cc2e`, source implementation `4b33f32`, and `9e23ae7` respectively; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, scoped UI action safety `#57`, and deterministic imported-asset fingerprints `#56` have complete feature-level acceptance; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Property-block application `#53` and external FBX dependency handling `#58` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
## Deliverables
| ID | Deliverable | State | Evidence or next action |
|----|-------------|-------|-------------------------|
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.5. |
| D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/), commands, and exact-implementation native evidence exist. Nominate a release candidate and rerun from a clean LFS-hydrated checkout. |
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.6. |
| D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/), commands, exact-implementation native evidence, and [fresh-checkout fingerprint protocol](../deterministic-asset-fingerprints/) exist. Nominate a release candidate and rerun the complete set from a clean LFS-hydrated checkout. |
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
## Workflow Coverage
@ -71,6 +71,7 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| Native file/folder dialogs | Implemented; `#52` closed with live 12-second compositor-responsiveness hold | Not rerun as one candidate |
| Guarded shutdown and clean savepoints | Implemented; `#55` source validation and all seven native workflows passed at source implementation `4b33f32` | Feature-level acceptance complete; not rerun as one candidate |
| Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate |
| Imported asset identity and generated artifacts | Implemented; `#56` uses shared BLAKE3 fingerprints, byte-preserving semantic publication, and normalized registry order | Fresh LFS checkout, native startup, mtime-only drift, validators, and artifact hashes passed at `cbd380a`; not rerun as one candidate |
## Candidate Validation Commands

View File

@ -2,7 +2,8 @@
## Shipped
- Stable asset registry (`assets/.index/registry.ron`) with import settings
- Stable asset registry (`assets/.index/registry.ron`) with import settings and content-addressed
model/texture/audio source fingerprints
- Prefab instances (`PrefabInstance`) + save-as-prefab workflow
- Unsaved-scene guards on New/Open/Recent
- Hierarchy additive select, reparent/unparent undo, multi-entity gizmo delta

View File

@ -119,7 +119,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Item | Status | Notes |
|------|--------|-------|
| `asset_db.rs` registry | Done | Stable UUIDs by path; import settings in details |
| `asset_db.rs` registry | Done | Stable UUIDs by path/content, deterministic model/texture/audio BLAKE3 fingerprints, normalized publication order, and import settings in details |
| Model import formats | Done | glTF/GLB + FBX (binary); `.fbm` sidecar copy on import |
| Thumbnails | Done | Textures via asset load; glTF albedo fast-path; FBX/untextured models via offscreen studio |
| Import settings per asset | Done | Scale/collider/LOD in registry + details pane |
@ -151,7 +151,7 @@ with implementation sequencing in
| Milestone | Exit condition | Status |
|-----------|----------------|--------|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. P0 #55 is complete at source implementation `4b33f32`, and follow-on P0 #57 scoped UI action safety is complete at `9e23ae7` after workspace, focused, independent-review, and native acceptance. No known P0 implementation blocker remains, but #50 is still the final candidate gate and production readiness is not established. Deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. P0 #55 is complete at source implementation `4b33f32`, follow-on P0 #57 scoped UI action safety is complete at `9e23ae7`, and P1 #56 deterministic imported-asset fingerprints pass workspace, clean-checkout, validator, hash-stability, and native acceptance at `cbd380a`. No known P0 implementation blocker remains, but #50 is still the final candidate gate and production readiness is not established. Packaged acceptance is deferred until requested by the project owner. |
Production readiness is not inferred from feature count. Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)