aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-03 15:58:17 +0300
committerValentin Popov <valentin@popov.link>2026-07-03 15:58:17 +0300
commitee9b318172bf50fadd90e0cfc364330fb85f778a (patch)
treee100304120ca431016d217d26e210bbe62d77d15
parent29bf4830cf9fffbf662051f4d9c55cbfa1dfd073 (diff)
downloadfparkan-ee9b318172bf50fadd90e0cfc364330fb85f778a.tar.xz
fparkan-ee9b318172bf50fadd90e0cfc364330fb85f778a.zip
fix(stage2): preserve byte-exact prototype identities
-rw-r--r--crates/fparkan-assets/src/lib.rs35
-rw-r--r--crates/fparkan-prototype/src/lib.rs121
2 files changed, 135 insertions, 21 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 26244ee..b128822 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -570,7 +570,7 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
#[derive(Clone, Debug, Eq, PartialEq)]
enum PreparedVisualSignature {
Mesh {
- archive: String,
+ archive: Vec<u8>,
name: Vec<u8>,
type_id: Option<u32>,
dependency_count: usize,
@@ -583,7 +583,7 @@ enum PreparedVisualSignature {
fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature {
match &proto.geometry {
PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh {
- archive: key.archive.as_str().to_string(),
+ archive: key.archive.identity_bytes().to_vec(),
name: key.name.0.clone(),
type_id: key.type_id,
dependency_count: proto.dependencies.len(),
@@ -1160,7 +1160,7 @@ fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
match &proto.geometry {
PrototypeGeometry::Mesh(key) => {
1_u8.hash(&mut hasher);
- key.archive.as_str().hash(&mut hasher);
+ key.archive.identity_bytes().hash(&mut hasher);
key.name.0.hash(&mut hasher);
key.type_id.hash(&mut hasher);
}
@@ -1270,6 +1270,35 @@ mod tests {
}
#[test]
+ fn stable_visual_id_uses_archive_identity_bytes() {
+ let first = EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
+ geometry: PrototypeGeometry::Mesh(ResourceKey {
+ archive: normalize_relative(b"DATA/\xFF.lib", PathPolicy::HostCompatible)
+ .expect("archive"),
+ name: resource_name(b"mesh.msh"),
+ type_id: Some(0x4853_454D),
+ }),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ dependencies: Vec::new(),
+ };
+ let second = EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
+ geometry: PrototypeGeometry::Mesh(ResourceKey {
+ archive: normalize_relative(b"DATA/\xFE.lib", PathPolicy::HostCompatible)
+ .expect("archive"),
+ name: resource_name(b"mesh.msh"),
+ type_id: Some(0x4853_454D),
+ }),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ dependencies: Vec::new(),
+ };
+
+ assert_ne!(stable_visual_id(&first), stable_visual_id(&second));
+ assert_ne!(prepared_visual_signature(&first), prepared_visual_signature(&second));
+ }
+
+ #[test]
#[ignore = "requires licensed corpus"]
fn prepares_real_unit_asset_plan() {
let root = fixture_root("IS");
diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs
index 0b1e460..e85322b 100644
--- a/crates/fparkan-prototype/src/lib.rs
+++ b/crates/fparkan-prototype/src/lib.rs
@@ -640,22 +640,20 @@ fn resolve_unit_dat_prototype_requests(
};
if let Ok(unit) = decode_unit_dat(&bytes) {
- if !unit.records.is_empty() {
- let mut prototypes = Vec::with_capacity(unit.records.len());
- for record in &unit.records {
- let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
- PrototypeError::Resource(ResourceError::Format(format!(
- "unit component {} did not resolve",
- String::from_utf8_lossy(cstr_bytes(&record.resource_raw))
- )))
- })?;
- prototypes.push(prototype);
- }
- return Ok(ResolvedPrototypeRequests {
- expected_count: unit.records.len(),
- prototypes,
- });
+ let mut prototypes = Vec::with_capacity(unit.records.len());
+ for record in &unit.records {
+ let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
+ PrototypeError::Resource(ResourceError::Format(format!(
+ "unit component {} did not resolve",
+ String::from_utf8_lossy(cstr_bytes(&record.resource_raw))
+ )))
+ })?;
+ prototypes.push(prototype);
}
+ return Ok(ResolvedPrototypeRequests {
+ expected_count: unit.records.len(),
+ prototypes,
+ });
}
let binding = decode_unit_dat_binding(&bytes)?;
@@ -906,8 +904,31 @@ pub fn build_prototype_graph_report(
}
fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
- let _ = err;
- edge
+ match err {
+ PrototypeError::Decode(_)
+ | PrototypeError::InvalidSize
+ | PrototypeError::InvalidUnitDatMagic(_)
+ if edge == PrototypeGraphEdge::MissionToUnitDat =>
+ {
+ PrototypeGraphEdge::MissionToUnitDat
+ }
+ PrototypeError::Resource(ResourceError::Format(message))
+ if message.starts_with("missing unit DAT:") =>
+ {
+ PrototypeGraphEdge::MissionToUnitDat
+ }
+ PrototypeError::Resource(ResourceError::Format(message))
+ if message.starts_with("unit component ") =>
+ {
+ PrototypeGraphEdge::UnitDatToComponent
+ }
+ PrototypeError::Resource(ResourceError::Format(message))
+ if message.contains("explicit mesh reference missing:") =>
+ {
+ PrototypeGraphEdge::PrototypeToMesh
+ }
+ _ => edge,
+ }
}
fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance {
@@ -1582,6 +1603,69 @@ mod tests {
}
#[test]
+ fn empty_unit_dat_resolves_as_zero_component_root() {
+ let mut vfs = MemoryVfs::default();
+ let dat_path = resource_archive_path(b"UNITS/AUTO/empty.dat").expect("dat path");
+ vfs.insert(dat_path, Arc::from(vec![0_u8; 8].into_boxed_slice()));
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let roots = [resource_name(b"UNITS/AUTO/empty.dat")];
+
+ let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots);
+
+ assert_eq!(graph.roots.len(), 1);
+ assert!(graph.prototype_requests.is_empty());
+ assert!(resolved.is_empty());
+ assert_eq!(report.unit_reference_count, 1);
+ assert_eq!(report.unit_component_count, 0);
+ assert_eq!(report.resolved_count, 0);
+ assert!(report.failures.is_empty());
+ assert!(report.is_success());
+ }
+
+ #[test]
+ fn malformed_unit_dat_failure_is_classified_on_unit_edge() {
+ let mut vfs = MemoryVfs::default();
+ let dat_path = resource_archive_path(b"UNITS/AUTO/bad.dat").expect("dat path");
+ vfs.insert(dat_path, Arc::from([1_u8, 2, 3].to_vec().into_boxed_slice()));
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let (_graph, _resolved, report) =
+ build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"UNITS/AUTO/bad.dat")]);
+
+ assert_eq!(report.failures.len(), 1);
+ assert_eq!(report.failures[0].edge, PrototypeGraphEdge::MissionToUnitDat);
+ }
+
+ #[test]
+ fn missing_unit_component_failure_is_classified_on_component_edge() {
+ let mut vfs = MemoryVfs::default();
+ let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path");
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ vfs.insert(
+ dat_path,
+ Arc::from(
+ build_unit_dat(&[(b"objects.rlb".as_slice(), b"missing_component".as_slice())])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(objects_path, Arc::from(build_nres(&[]).into_boxed_slice()));
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let (_graph, _resolved, report) = build_prototype_graph_report(
+ &repo,
+ vfs.as_ref(),
+ &[resource_name(b"UNITS/AUTO/compound.dat")],
+ );
+
+ assert_eq!(report.failures.len(), 1);
+ assert_eq!(report.failures[0].edge, PrototypeGraphEdge::UnitDatToComponent);
+ assert!(report.failures[0].message.contains("missing_component"));
+ }
+
+ #[test]
fn unit_dat_expands_components_in_order() {
let mut vfs = MemoryVfs::default();
let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path");
@@ -1896,7 +1980,7 @@ mod tests {
assert_eq!(report.failures[0].resource_raw, b"broken");
assert_eq!(
report.failures[0].edge,
- PrototypeGraphEdge::MissionToObjectsRegistry
+ PrototypeGraphEdge::PrototypeToMesh
);
assert!(report.failures[0].message.contains("broken"));
assert!(report.failures[0]
@@ -1929,6 +2013,7 @@ mod tests {
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].resource_raw, b"broken");
+ assert_eq!(report.failures[0].edge, PrototypeGraphEdge::PrototypeToMesh);
assert!(report.failures[0]
.message
.contains("static.rlb:missing.msh"));