Compare commits
No commits in common. "7e99243a78ee99207763ec92e80bcf6dd03f7151" and "a048233fceab86649f8c1004137a59f0cd62ac12" have entirely different histories.
7e99243a78
...
a048233fce
@ -1,38 +0,0 @@
|
||||
# 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.
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -63,11 +63,6 @@ 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
|
||||
|
||||
|
||||
@ -377,7 +377,6 @@ 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
|
||||
|
||||
@ -429,9 +428,6 @@ 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
@ -1,5 +1,5 @@
|
||||
(
|
||||
schema_version: 4,
|
||||
schema_version: 3,
|
||||
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
|
||||
label: "RobotExpressive",
|
||||
source: (
|
||||
@ -7,7 +7,7 @@
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
|
||||
modified_unix_secs: 1783799272,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
(
|
||||
schema_version: 4,
|
||||
schema_version: 3,
|
||||
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
|
||||
label: "robot_expressive",
|
||||
source: (
|
||||
@ -7,7 +7,7 @@
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
|
||||
modified_unix_secs: 1783750957,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
(
|
||||
schema_version: 4,
|
||||
schema_version: 3,
|
||||
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
|
||||
label: "painted_wooden_chair_02_2k",
|
||||
source: (
|
||||
@ -7,7 +7,7 @@
|
||||
format: "fbx",
|
||||
fingerprint: (
|
||||
byte_len: 59964,
|
||||
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
|
||||
modified_unix_secs: 1780713434,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use serde::Deserialize;
|
||||
use shared::{
|
||||
@ -13,7 +14,6 @@ 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,9 +32,15 @@ pub fn refresh_animation_artifact(record: &mut AssetRecord) -> Result<AnimationM
|
||||
record.dependencies.sort();
|
||||
record.dependencies.dedup();
|
||||
|
||||
if write_pretty_ron_if_changed(&path, &manifest)
|
||||
.map_err(|error| format!("could not publish animation manifest {path}: {error}"))?
|
||||
{
|
||||
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}"))?;
|
||||
bevy::log::info!(
|
||||
"Animation manifest refreshed: source={} artifact={} skeletons={} clips={} runtime_supported={}",
|
||||
record.path,
|
||||
@ -57,7 +63,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(&bytes);
|
||||
let fingerprint = source_fingerprint(&record.path, &bytes)?;
|
||||
let format = source_format(&record.path)?;
|
||||
match format.as_str() {
|
||||
"gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, &bytes),
|
||||
@ -576,8 +582,19 @@ fn fbx_matrix_bytes(matrix: &ufbx::Matrix) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_fingerprint(bytes: &[u8]) -> AnimationSourceFingerprint {
|
||||
AnimationSourceFingerprint::from_bytes(bytes)
|
||||
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_format(path: &str) -> Result<String, String> {
|
||||
@ -609,7 +626,6 @@ 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(),
|
||||
}
|
||||
@ -738,30 +754,6 @@ 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 =
|
||||
|
||||
@ -2,12 +2,9 @@
|
||||
|
||||
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);
|
||||
@ -96,8 +93,6 @@ 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)]
|
||||
@ -127,10 +122,9 @@ fn sync_registry_from_browser(
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_records = std::mem::take(&mut registry.records);
|
||||
let mut existing: HashMap<String, AssetRecord> = previous_records
|
||||
.iter()
|
||||
.cloned()
|
||||
let mut existing: HashMap<String, AssetRecord> = registry
|
||||
.records
|
||||
.drain(..)
|
||||
.map(|record| (record.path.clone(), record))
|
||||
.collect();
|
||||
let current_paths = assets
|
||||
@ -152,30 +146,12 @@ 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(|| {
|
||||
source_fingerprint.as_ref().and_then(|fingerprint| {
|
||||
take_uniquely_moved_import_record(
|
||||
&mut existing,
|
||||
¤t_paths,
|
||||
&kind_tag,
|
||||
fingerprint,
|
||||
)
|
||||
})
|
||||
take_uniquely_moved_model_record(&mut existing, ¤t_paths, &path, &kind_tag)
|
||||
}) {
|
||||
if prior.path != path {
|
||||
info!(
|
||||
"Asset registry preserved imported source identity {} across move {} -> {}",
|
||||
"Asset registry preserved model identity {} across move {} -> {}",
|
||||
prior.id.as_string(),
|
||||
prior.path,
|
||||
path
|
||||
@ -183,26 +159,18 @@ fn sync_registry_from_browser(
|
||||
prior.path = path.clone();
|
||||
}
|
||||
prior.label = asset.label.clone();
|
||||
prior.kind_tag = kind_tag.clone();
|
||||
prior.kind_tag = kind_tag;
|
||||
prior
|
||||
} else {
|
||||
AssetRecord {
|
||||
id: AssetId::new(),
|
||||
path: path.clone(),
|
||||
label: asset.label.clone(),
|
||||
kind_tag: kind_tag.clone(),
|
||||
source_fingerprint: None,
|
||||
kind_tag,
|
||||
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!(
|
||||
@ -215,8 +183,7 @@ fn sync_registry_from_browser(
|
||||
next_records.push(record);
|
||||
}
|
||||
|
||||
sort_registry_records(&mut next_records);
|
||||
let changed = previous_records != next_records;
|
||||
let changed = registry.records != next_records;
|
||||
registry.records = next_records;
|
||||
if changed {
|
||||
registry.index_dirty = true;
|
||||
@ -231,32 +198,26 @@ fn sync_registry_from_browser(
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
fn take_uniquely_moved_model_record(
|
||||
existing: &mut HashMap<String, AssetRecord>,
|
||||
current_paths: &HashSet<String>,
|
||||
new_path: &str,
|
||||
kind_tag: &str,
|
||||
fingerprint: &AssetSourceFingerprint,
|
||||
) -> Option<AssetRecord> {
|
||||
if !imported_source_kind(kind_tag) {
|
||||
if kind_tag != "Model" {
|
||||
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 != kind_tag {
|
||||
if current_paths.contains(old_path) || record.kind_tag != "Model" {
|
||||
return None;
|
||||
}
|
||||
recorded_source_fingerprint(record)
|
||||
.is_some_and(|prior| prior == *fingerprint)
|
||||
.then(|| old_path.clone())
|
||||
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())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.len() != 1 {
|
||||
@ -265,15 +226,6 @@ fn take_uniquely_moved_import_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
|
||||
@ -310,16 +262,11 @@ 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,
|
||||
source_fingerprint,
|
||||
kind_tag: kind_tag.into(),
|
||||
import_settings: ImportSettings::default(),
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
@ -345,9 +292,6 @@ pub fn update_import_settings(
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if record.import_settings == settings {
|
||||
return false;
|
||||
}
|
||||
record.import_settings = settings;
|
||||
registry.index_dirty = true;
|
||||
true
|
||||
@ -358,7 +302,13 @@ pub fn registry_index_path() -> &'static str {
|
||||
}
|
||||
|
||||
pub fn save_registry(registry: &AssetRegistry) -> Result<(), String> {
|
||||
write_pretty_ron_if_changed(registry_index_path(), ®istry.records).map(|_| ())
|
||||
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(®istry.records, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_registry() -> AssetRegistry {
|
||||
@ -383,101 +333,6 @@ 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"));
|
||||
@ -496,6 +351,7 @@ 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(),
|
||||
@ -515,7 +371,6 @@ 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()
|
||||
@ -530,13 +385,12 @@ 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_import_record(
|
||||
let moved = take_uniquely_moved_model_record(
|
||||
&mut existing,
|
||||
&HashSet::new(),
|
||||
&source_path.to_string_lossy(),
|
||||
"Model",
|
||||
&fingerprint,
|
||||
)
|
||||
.expect("unique moved model should retain its registry record");
|
||||
|
||||
@ -552,10 +406,13 @@ 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_import_record(&mut existing, ¤t_paths, "Model", &fingerprint);
|
||||
let moved = take_uniquely_moved_model_record(
|
||||
&mut existing,
|
||||
¤t_paths,
|
||||
&source_path.to_string_lossy(),
|
||||
"Model",
|
||||
);
|
||||
|
||||
assert!(moved.is_none());
|
||||
assert_eq!(existing.len(), 1);
|
||||
|
||||
@ -1548,7 +1548,6 @@ 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(),
|
||||
}],
|
||||
@ -1631,6 +1630,7 @@ mod tests {
|
||||
format: "glb".into(),
|
||||
fingerprint: shared::AnimationSourceFingerprint {
|
||||
byte_len: 1,
|
||||
modified_unix_secs: 0,
|
||||
content_hash: "fixture".into(),
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
@ -1663,7 +1663,6 @@ 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()),
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
pub mod animation;
|
||||
pub mod asset_db;
|
||||
mod catalog;
|
||||
mod fingerprint;
|
||||
mod import;
|
||||
pub mod materials;
|
||||
pub mod operators;
|
||||
|
||||
@ -228,6 +228,7 @@ mod tests {
|
||||
format: "glb".into(),
|
||||
fingerprint: AnimationSourceFingerprint {
|
||||
byte_len: 1,
|
||||
modified_unix_secs: 0,
|
||||
content_hash: "fixture".into(),
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
@ -264,7 +265,6 @@ 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,7 +352,6 @@ mod tests {
|
||||
path: path.clone(),
|
||||
label: "Impact".into(),
|
||||
kind_tag: "AudioClip".into(),
|
||||
source_fingerprint: None,
|
||||
import_settings: Default::default(),
|
||||
dependencies: Vec::new(),
|
||||
}],
|
||||
@ -503,7 +502,6 @@ mod tests {
|
||||
path: material_path.clone(),
|
||||
label: "Concrete".into(),
|
||||
kind_tag: "Material".into(),
|
||||
source_fingerprint: None,
|
||||
import_settings: Default::default(),
|
||||
dependencies: Vec::new(),
|
||||
}],
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use bevy::gltf::GltfAssetLabel;
|
||||
use bevy::prelude::*;
|
||||
@ -14,13 +15,11 @@ use crate::asset_db::{
|
||||
AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
|
||||
};
|
||||
use shared::{
|
||||
AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet,
|
||||
RendererMaterialSlot, StaticMeshRenderer, StaticMeshRendererEntry,
|
||||
ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot,
|
||||
StaticMeshRenderer, StaticMeshRendererEntry,
|
||||
};
|
||||
|
||||
use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed};
|
||||
|
||||
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4;
|
||||
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 3;
|
||||
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@ -43,7 +42,11 @@ pub struct StaticMeshSource {
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
pub type StaticMeshSourceFingerprint = AssetSourceFingerprint;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshSourceFingerprint {
|
||||
pub byte_len: u64,
|
||||
pub modified_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshImportSnapshot {
|
||||
@ -131,9 +134,16 @@ pub fn refresh_static_mesh_artifact(
|
||||
manifest.source.dependencies.dedup();
|
||||
record.dependencies = manifest.source.dependencies.clone();
|
||||
|
||||
if write_pretty_ron_if_changed(&path, &manifest)
|
||||
.map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))?
|
||||
{
|
||||
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}"))?;
|
||||
info!(
|
||||
"Static mesh manifest refreshed: source={} artifact={} parts={}",
|
||||
record.path,
|
||||
@ -277,7 +287,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 = fingerprint_file(&record.path)?;
|
||||
let fingerprint = source_fingerprint(&record.path)?;
|
||||
let import = StaticMeshImportSnapshot {
|
||||
scale: record.import_settings.scale,
|
||||
generate_collider: record.import_settings.generate_collider,
|
||||
@ -621,6 +631,20 @@ 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();
|
||||
@ -648,7 +672,7 @@ mod tests {
|
||||
format: "glb".into(),
|
||||
fingerprint: StaticMeshSourceFingerprint {
|
||||
byte_len: 42,
|
||||
content_hash: "a".repeat(64),
|
||||
modified_unix_secs: 10,
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
@ -717,55 +741,6 @@ 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();
|
||||
@ -810,7 +785,6 @@ mod tests {
|
||||
path,
|
||||
label: "Robot Expressive".into(),
|
||||
kind_tag: "Model".into(),
|
||||
source_fingerprint: None,
|
||||
import_settings: ImportSettings::default(),
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
|
||||
@ -884,6 +884,7 @@ mod tests {
|
||||
format: "glb".into(),
|
||||
fingerprint: AnimationSourceFingerprint {
|
||||
byte_len: 42,
|
||||
modified_unix_secs: 1,
|
||||
content_hash: "hash".into(),
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
|
||||
@ -1267,6 +1267,7 @@ mod tests {
|
||||
format: "glb".into(),
|
||||
fingerprint: AnimationSourceFingerprint {
|
||||
byte_len: 1,
|
||||
modified_unix_secs: 0,
|
||||
content_hash: "fixture".into(),
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
|
||||
@ -9,13 +9,12 @@ 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,
|
||||
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,
|
||||
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,
|
||||
NAVIGATION_GENERATED_ARTIFACT_DIRECTORY,
|
||||
};
|
||||
|
||||
@ -323,8 +322,6 @@ struct RegistryAssetRecord {
|
||||
path: String,
|
||||
kind_tag: String,
|
||||
#[serde(default)]
|
||||
source_fingerprint: Option<AssetSourceFingerprint>,
|
||||
#[serde(default)]
|
||||
import_settings: RegistryImportSettings,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<String>,
|
||||
@ -485,96 +482,10 @@ 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()
|
||||
@ -798,12 +709,10 @@ struct StaticMeshManifestView {
|
||||
struct StaticMeshSourceView {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
fingerprint: AssetSourceFingerprint,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 4;
|
||||
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
|
||||
for path in ron_files(
|
||||
@ -845,16 +754,6 @@ 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,
|
||||
@ -1076,7 +975,12 @@ fn validate_animation_manifest_contract(
|
||||
}
|
||||
|
||||
let fingerprint = &manifest.source.fingerprint;
|
||||
if fingerprint.byte_len == 0 || !fingerprint.is_complete() {
|
||||
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 {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "animation.manifest_fingerprint_missing".into(),
|
||||
@ -3167,7 +3071,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_mesh_manifest_schema_v4_is_current() {
|
||||
fn static_mesh_manifest_schema_v3_is_current() {
|
||||
let root = fixture_root();
|
||||
let source_path = "assets/models/static.glb";
|
||||
std::fs::create_dir_all(root.join("assets/models")).unwrap();
|
||||
@ -3177,8 +3081,7 @@ 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}\",fingerprint:(byte_len:5,content_hash:\"{}\"),dependencies:[]),warnings:[])",
|
||||
blake3::hash(b"model").to_hex()
|
||||
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",dependencies:[]),warnings:[])"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
@ -3192,70 +3095,6 @@ 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"),
|
||||
@ -3289,6 +3128,7 @@ 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(),
|
||||
@ -3348,12 +3188,10 @@ 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\",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
|
||||
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:{manifest}),dependencies:[])]"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
@ -3367,12 +3205,10 @@ 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\",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
|
||||
"[(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:[])]"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
@ -3796,49 +3632,6 @@ 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();
|
||||
|
||||
@ -266,8 +266,13 @@ pub struct AnimationManifestSource {
|
||||
pub dependencies: Vec<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 AnimationSourceFingerprint {
|
||||
pub byte_len: u64,
|
||||
pub modified_unix_secs: u64,
|
||||
/// BLAKE3 hash of the source file bytes.
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnimationSkeletonRecord {
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
mod actor;
|
||||
mod animation;
|
||||
mod asset_fingerprint;
|
||||
pub mod brush_math;
|
||||
mod components;
|
||||
mod hydration;
|
||||
@ -19,7 +18,6 @@ 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,
|
||||
|
||||
@ -57,7 +57,6 @@ 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
|
||||
|
||||
@ -98,7 +97,6 @@ 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)
|
||||
|
||||
@ -13,12 +13,7 @@ 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, 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.
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
@ -40,6 +35,4 @@ 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.
|
||||
|
||||
@ -42,12 +42,6 @@ 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.
|
||||
@ -64,5 +58,3 @@ 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.
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
# 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.
|
||||
@ -44,7 +44,6 @@ 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)
|
||||
@ -64,7 +63,6 @@ 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 |
|
||||
@ -93,7 +91,6 @@ 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).
|
||||
|
||||
@ -68,11 +68,10 @@ 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 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.
|
||||
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.
|
||||
|
||||
Scene schema v3 migrates legacy animated actors that paired `ModelRef` with
|
||||
`AnimationControllerDesc` to the dedicated skinned renderer. Generic `ImportedModel + ModelRef`
|
||||
@ -84,12 +83,6 @@ 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,
|
||||
|
||||
@ -224,11 +224,7 @@ 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.
|
||||
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.
|
||||
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
|
||||
|
||||
@ -14,7 +14,6 @@ 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` |
|
||||
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
- Gitea attachment asset 39: `blacksite-issue56-fresh-fixed-cbd380a.png`, 3426x1384 sRGB PNG,
|
||||
1,787,700 bytes.
|
||||
- SHA-256: `2748ae3de31ff97933c9793b90475ffd38d54d7e9dd5d65937b699f81c73c9bc`.
|
||||
|
||||

|
||||
|
||||
- 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.
|
||||
@ -1,6 +1,6 @@
|
||||
# Production-Readiness Acceptance Matrix
|
||||
|
||||
**Matrix version:** 0.6
|
||||
**Matrix version:** 0.5
|
||||
|
||||
**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`, 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. |
|
||||
| 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. |
|
||||
| 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, 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. |
|
||||
| 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. |
|
||||
| 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`, 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. |
|
||||
| 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. |
|
||||
|
||||
## Deliverables
|
||||
|
||||
| ID | Deliverable | State | Evidence or next action |
|
||||
|----|-------------|-------|-------------------------|
|
||||
| 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. |
|
||||
| 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. |
|
||||
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
|
||||
|
||||
## Workflow Coverage
|
||||
@ -71,7 +71,6 @@ 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
|
||||
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
## Shipped
|
||||
|
||||
- Stable asset registry (`assets/.index/registry.ron`) with import settings and content-addressed
|
||||
model/texture/audio source fingerprints
|
||||
- Stable asset registry (`assets/.index/registry.ron`) with import settings
|
||||
- Prefab instances (`PrefabInstance`) + save-as-prefab workflow
|
||||
- Unsaved-scene guards on New/Open/Recent
|
||||
- Hierarchy additive select, reparent/unparent undo, multi-entity gizmo delta
|
||||
|
||||
@ -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/content, deterministic model/texture/audio BLAKE3 fingerprints, normalized publication order, and import settings in details |
|
||||
| `asset_db.rs` registry | Done | Stable UUIDs by path; 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`, 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. |
|
||||
| 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. |
|
||||
|
||||
Production readiness is not inferred from feature count. Gitea
|
||||
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user