aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-03 20:49:42 +0300
committerValentin Popov <valentin@popov.link>2026-07-03 20:49:42 +0300
commit44627cd532aec53e25caf3497cd976563385b00d (patch)
treef3f70a20506720b41beb853f7a9c3f0f2eee7bb1
parentf34c422b20fd1fc1b4bf71ebf448b3c42f104226 (diff)
downloadfparkan-44627cd532aec53e25caf3497cd976563385b00d.tar.xz
fparkan-44627cd532aec53e25caf3497cd976563385b00d.zip
fix: guard prepared asset id collisions
-rw-r--r--crates/fparkan-assets/src/lib.rs399
-rw-r--r--fixtures/acceptance/coverage.tsv1
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md1
3 files changed, 382 insertions, 19 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 8fc2501..47c8edb 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -39,7 +39,7 @@ pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_texm, TexmDocument, TexmError};
-use std::collections::{HashMap, HashSet};
+use std::collections::{hash_map::Entry, HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
@@ -586,15 +586,33 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
root_prototype_spans: &[std::ops::Range<usize>],
prototypes: &[EffectivePrototype],
) -> Result<MissionAssets, AssetError> {
+ prepare_mission_assets_with_repository_internal(
+ repository,
+ root_prototype_spans,
+ prototypes,
+ AssetIdentityPolicy::default(),
+ )
+}
+
+fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
+ repository: &R,
+ root_prototype_spans: &[std::ops::Range<usize>],
+ prototypes: &[EffectivePrototype],
+ identity_policy: AssetIdentityPolicy,
+) -> Result<MissionAssets, AssetError> {
if prototypes.is_empty() {
return Ok(MissionAssets::default());
}
let mut visual_index_by_id: HashMap<AssetId<PreparedVisual>, PreparedVisualSignature> =
HashMap::new();
- let mut model_ids = HashSet::new();
- let mut wear_ids = HashSet::new();
- let mut material_ids = HashSet::new();
- let mut texture_ids = HashSet::new();
+ let mut model_index_by_id: HashMap<AssetId<PreparedModel>, PreparedModelSignature> =
+ HashMap::new();
+ let mut wear_index_by_id: HashMap<AssetId<PreparedWear>, PreparedWearSignature> =
+ HashMap::new();
+ let mut material_index_by_id: HashMap<AssetId<PreparedMaterial>, PreparedMaterialSignature> =
+ HashMap::new();
+ let mut texture_index_by_id: HashMap<AssetId<PreparedTexture>, PreparedTextureSignature> =
+ HashMap::new();
let mut models = Vec::new();
let mut wears = Vec::new();
let mut materials = Vec::new();
@@ -603,7 +621,7 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
let mut prototype_visual_ids = Vec::with_capacity(prototypes.len());
for proto in prototypes {
- let visual_id = AssetId::new(stable_visual_id(proto));
+ let visual_id = AssetId::new((identity_policy.visual_id)(proto));
let signature = prepared_visual_signature(proto);
match visual_index_by_id.get(&visual_id) {
Some(existing) if existing != &signature => {
@@ -614,7 +632,8 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
Some(_) => {}
None => {
visual_index_by_id.insert(visual_id, signature);
- let bundle = prepare_visual_with_repository_internal(repository, proto)?;
+ let bundle =
+ prepare_visual_with_repository_internal(repository, proto, identity_policy)?;
if bundle.visual.id != visual_id {
// Defensive check. stable IDs are deterministic for the same inputs.
return Err(AssetError::InvalidPrototype(
@@ -622,22 +641,42 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
));
}
if let Some(model) = bundle.model {
- if model_ids.insert(model.id) {
+ if insert_asset_signature(
+ &mut model_index_by_id,
+ model.id,
+ prepared_model_signature(&model),
+ "model",
+ )? {
models.push(model);
}
}
if let Some(wear) = bundle.wear {
- if wear_ids.insert(wear.id) {
+ if insert_asset_signature(
+ &mut wear_index_by_id,
+ wear.id,
+ prepared_wear_signature(&wear),
+ "wear",
+ )? {
wears.push(wear);
}
}
for material in bundle.materials {
- if material_ids.insert(material.id) {
+ if insert_asset_signature(
+ &mut material_index_by_id,
+ material.id,
+ prepared_material_signature(&material),
+ "material",
+ )? {
materials.push(material);
}
}
for texture in bundle.textures {
- if texture_ids.insert(texture.id) {
+ if insert_asset_signature(
+ &mut texture_index_by_id,
+ texture.id,
+ prepared_texture_signature(&texture),
+ "texture",
+ )? {
textures.push(texture);
}
}
@@ -688,6 +727,65 @@ enum PreparedVisualSignature {
},
}
+#[derive(Clone, Copy)]
+struct AssetIdentityPolicy {
+ visual_id: fn(&EffectivePrototype) -> u64,
+ model_id: fn(&EffectivePrototype) -> u64,
+ wear_id: fn(&EffectivePrototype) -> u64,
+ material_id: fn(&EffectivePrototype, u16, &ResourceName) -> u64,
+ texture_id: fn(&ResourceKey, PreparedTextureUsage) -> u64,
+}
+
+impl Default for AssetIdentityPolicy {
+ fn default() -> Self {
+ Self {
+ visual_id: stable_visual_id,
+ model_id: stable_model_id,
+ wear_id: stable_wear_id,
+ material_id: stable_material_id,
+ texture_id: stable_texture_id,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct ResourceSignature {
+ archive: Vec<u8>,
+ name: Vec<u8>,
+ type_id: Option<u32>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PreparedModelSignature {
+ source: ResourceSignature,
+ node_count: usize,
+ slot_count: usize,
+ batch_count: usize,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PreparedWearSignature {
+ source: ResourceSignature,
+ material_slots: usize,
+ lightmap_slots: usize,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PreparedMaterialSignature {
+ source: ResourceSignature,
+ texture_requests: Vec<Vec<u8>>,
+ lightmap_requests: Vec<Vec<u8>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct PreparedTextureSignature {
+ source: ResourceSignature,
+ usage: PreparedTextureUsage,
+ width: u32,
+ height: u32,
+ mip_count: usize,
+}
+
fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature {
match &proto.geometry {
PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh {
@@ -702,6 +800,82 @@ fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignat
}
}
+fn resource_signature(key: &ResourceKey) -> ResourceSignature {
+ ResourceSignature {
+ archive: key.archive.identity_bytes().to_vec(),
+ name: key.name.0.clone(),
+ type_id: key.type_id,
+ }
+}
+
+fn prepared_model_signature(model: &PreparedModel) -> PreparedModelSignature {
+ PreparedModelSignature {
+ source: resource_signature(&model.source),
+ node_count: model.validated.node_count,
+ slot_count: model.validated.slots.len(),
+ batch_count: model.validated.batches.len(),
+ }
+}
+
+fn prepared_wear_signature(wear: &PreparedWear) -> PreparedWearSignature {
+ PreparedWearSignature {
+ source: resource_signature(&wear.source),
+ material_slots: wear.table.entries.len(),
+ lightmap_slots: wear.table.lightmaps.len(),
+ }
+}
+
+fn prepared_material_signature(material: &PreparedMaterial) -> PreparedMaterialSignature {
+ PreparedMaterialSignature {
+ source: resource_signature(&material.source),
+ texture_requests: material
+ .texture_requests
+ .iter()
+ .map(|name| name.0.clone())
+ .collect(),
+ lightmap_requests: material
+ .lightmap_requests
+ .iter()
+ .map(|name| name.0.clone())
+ .collect(),
+ }
+}
+
+fn prepared_texture_signature(texture: &PreparedTexture) -> PreparedTextureSignature {
+ PreparedTextureSignature {
+ source: resource_signature(&texture.source),
+ usage: texture.usage,
+ width: texture.texm.width(),
+ height: texture.texm.height(),
+ mip_count: texture.texm.mip_count(),
+ }
+}
+
+fn insert_asset_signature<T, S>(
+ signatures: &mut HashMap<AssetId<T>, S>,
+ id: AssetId<T>,
+ signature: S,
+ label: &str,
+) -> Result<bool, AssetError>
+where
+ S: Eq,
+{
+ match signatures.entry(id) {
+ Entry::Occupied(existing) => {
+ if existing.get() != &signature {
+ return Err(AssetError::InvalidPrototype(format!(
+ "stable {label} id collision between unrelated assets"
+ )));
+ }
+ Ok(false)
+ }
+ Entry::Vacant(entry) => {
+ entry.insert(signature);
+ Ok(true)
+ }
+ }
+}
+
/// Extends a prototype dependency report with visual dependency failures.
///
/// This function validates WEAR/material/TEXM/LIGHTMAP resolution for each resolved
@@ -1059,7 +1233,10 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
) -> Result<PreparedVisual, AssetError> {
- Ok(prepare_visual_with_repository_internal(repository, proto)?.visual)
+ Ok(
+ prepare_visual_with_repository_internal(repository, proto, AssetIdentityPolicy::default())?
+ .visual,
+ )
}
struct PreparedVisualBundle {
@@ -1073,6 +1250,7 @@ struct PreparedVisualBundle {
fn prepare_visual_with_repository_internal<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
+ identity_policy: AssetIdentityPolicy,
) -> Result<PreparedVisualBundle, AssetError> {
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
return Ok(PreparedVisualBundle {
@@ -1091,7 +1269,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
.map_err(AssetError::Nres)?;
let msh_document = decode_msh(&nres).map_err(AssetError::Msh)?;
let model = validate_msh(&msh_document).map_err(AssetError::Msh)?;
- let model_id = AssetId::new(stable_model_id(proto));
+ let model_id = AssetId::new((identity_policy.model_id)(proto));
let prepared_model = PreparedModel {
id: model_id,
source: mesh_key.clone(),
@@ -1107,7 +1285,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
};
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
.map_err(AssetError::Material)?;
- let wear_id = AssetId::new(stable_wear_id(proto));
+ let wear_id = AssetId::new((identity_policy.wear_id)(proto));
let prepared_wear = PreparedWear {
id: wear_id,
source: wear_key.clone(),
@@ -1134,7 +1312,11 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
let material =
resolve_material(repository, &wear, material_index).map_err(AssetError::Material)?;
material_count += 1;
- let material_id = AssetId::new(stable_material_id(proto, material_index, &material.name));
+ let material_id = AssetId::new((identity_policy.material_id)(
+ proto,
+ material_index,
+ &material.name,
+ ));
material_ids.push(material_id);
let material_key = ResourceKey {
archive: parse_path("material.lib")?,
@@ -1152,8 +1334,12 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
});
for texture in texture_requests {
- let prepared_texture =
- prepare_texture(repository, &texture, PreparedTextureUsage::Diffuse)?;
+ let prepared_texture = prepare_texture(
+ repository,
+ &texture,
+ PreparedTextureUsage::Diffuse,
+ identity_policy,
+ )?;
texture_ids.push(prepared_texture.id);
prepared_textures.push(prepared_texture);
texture_count += 1;
@@ -1165,6 +1351,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
repository,
&lightmap.lightmap,
PreparedTextureUsage::Lightmap,
+ identity_policy,
)?;
lightmap_ids.push(prepared_lightmap.id);
prepared_textures.push(prepared_lightmap);
@@ -1173,7 +1360,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
Ok(PreparedVisualBundle {
visual: PreparedVisual {
- id: AssetId::new(stable_visual_id(proto)),
+ id: AssetId::new((identity_policy.visual_id)(proto)),
mesh: Some(mesh_key.clone()),
model_id: Some(model_id),
wear_id: Some(wear_id),
@@ -1426,6 +1613,7 @@ fn prepare_texture<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
usage: PreparedTextureUsage,
+ identity_policy: AssetIdentityPolicy,
) -> Result<PreparedTexture, AssetError> {
let (archive, label) = match usage {
PreparedTextureUsage::Diffuse => (TEXTURES_ARCHIVE, "texture"),
@@ -1441,7 +1629,7 @@ fn prepare_texture<R: ResourceRepository>(
};
let texm = decode_texm(bytes).map_err(AssetError::Texture)?;
Ok(PreparedTexture {
- id: AssetId::new(stable_texture_id(&key, usage)),
+ id: AssetId::new((identity_policy.texture_id)(&key, usage)),
source: key,
texm,
usage,
@@ -2005,6 +2193,50 @@ mod tests {
}
#[test]
+ fn forced_model_id_collision_is_rejected() {
+ assert_forced_collision(
+ AssetIdentityPolicy {
+ model_id: |_| 7,
+ ..AssetIdentityPolicy::default()
+ },
+ "stable model id collision",
+ );
+ }
+
+ #[test]
+ fn forced_wear_id_collision_is_rejected() {
+ assert_forced_collision(
+ AssetIdentityPolicy {
+ wear_id: |_| 11,
+ ..AssetIdentityPolicy::default()
+ },
+ "stable wear id collision",
+ );
+ }
+
+ #[test]
+ fn forced_material_id_collision_is_rejected() {
+ assert_forced_collision(
+ AssetIdentityPolicy {
+ material_id: |_, _, _| 13,
+ ..AssetIdentityPolicy::default()
+ },
+ "stable material id collision",
+ );
+ }
+
+ #[test]
+ fn forced_texture_id_collision_is_rejected() {
+ assert_forced_collision(
+ AssetIdentityPolicy {
+ texture_id: |_, _| 17,
+ ..AssetIdentityPolicy::default()
+ },
+ "stable texture id collision",
+ );
+ }
+
+ #[test]
fn graph_report_uses_strict_texture_archive_policy() {
let mesh_key = ResourceKey {
archive: parse_path("static.rlb").expect("archive"),
@@ -2122,6 +2354,20 @@ mod tests {
assert_eq!(plan.texture_count, 77);
}
+ fn assert_forced_collision(policy: AssetIdentityPolicy, expected: &str) {
+ let (repo, prototypes) = collision_fixture();
+
+ let err = prepare_mission_assets_with_repository_internal(
+ &repo,
+ &[0..1, 1..2],
+ &prototypes,
+ policy,
+ )
+ .expect_err("collision should fail");
+
+ assert!(err.to_string().contains(expected), "{err}");
+ }
+
fn fixture_root(part: &str) -> PathBuf {
let variable = match part {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
@@ -2139,6 +2385,121 @@ mod tests {
root
}
+ fn collision_fixture() -> (CachedResourceRepository, Vec<EffectivePrototype>) {
+ let msh = minimal_model_archive();
+ let mat0_a = mat0_with_texture(b"TEX_A");
+ let mat0_b = mat0_with_texture(b"TEX_B");
+ let texm_a = texm_payload();
+ let texm_b = texm_payload();
+ let lightmap_a = texm_payload();
+ let lightmap_b = texm_payload();
+ let repo = repository_with_archives_meta(&[
+ (
+ "static.rlb",
+ &[
+ TestNresEntry {
+ name: b"tree_a.msh",
+ payload: &msh,
+ type_id: 0x4853_454D,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"tree_a.wea",
+ payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
+ type_id: WEAR_KIND,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"tree_b.msh",
+ payload: &msh,
+ type_id: 0x4853_454D,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"tree_b.wea",
+ payload: b"1\n0 MAT_B\n\nLIGHTMAPS\n1\n0 LM_B\n",
+ type_id: WEAR_KIND,
+ attr2: 0,
+ },
+ ],
+ ),
+ (
+ "material.lib",
+ &[
+ TestNresEntry {
+ name: b"MAT_A",
+ payload: &mat0_a,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"MAT_B",
+ payload: &mat0_b,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ },
+ ],
+ ),
+ (
+ TEXTURES_ARCHIVE,
+ &[
+ TestNresEntry {
+ name: b"TEX_A",
+ payload: &texm_a,
+ type_id: 0,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"TEX_B",
+ payload: &texm_b,
+ type_id: 0,
+ attr2: 0,
+ },
+ ],
+ ),
+ (
+ LIGHTMAP_ARCHIVE,
+ &[
+ TestNresEntry {
+ name: b"LM_A",
+ payload: &lightmap_a,
+ type_id: 0,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"LM_B",
+ payload: &lightmap_b,
+ type_id: 0,
+ attr2: 0,
+ },
+ ],
+ ),
+ ]);
+ let prototypes = vec![
+ EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree_a")),
+ geometry: PrototypeGeometry::Mesh(ResourceKey {
+ archive: parse_path("static.rlb").expect("archive"),
+ name: resource_name(b"tree_a.msh"),
+ type_id: Some(0x4853_454D),
+ }),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ dependencies: Vec::new(),
+ },
+ EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")),
+ geometry: PrototypeGeometry::Mesh(ResourceKey {
+ archive: parse_path("static.rlb").expect("archive"),
+ name: resource_name(b"tree_b.msh"),
+ type_id: Some(0x4853_454D),
+ }),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ dependencies: Vec::new(),
+ },
+ ];
+ (repo, prototypes)
+ }
+
fn repository_with_archives(
archives: &[(&str, &[(&[u8], &[u8])])],
) -> CachedResourceRepository {
diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv
index 76a810b..741871d 100644
--- a/fixtures/acceptance/coverage.tsv
+++ b/fixtures/acceptance/coverage.tsv
@@ -214,6 +214,7 @@ S2-ASSET-MODEL-001 covered cargo test -p fparkan-assets --offline prepare_single
S2-ASSET-WEAR-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-MATERIAL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-TEXM-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
+S2-ASSET-ID-001 covered cargo test -p fparkan-assets --offline forced_model_id_collision_is_rejected forced_wear_id_collision_is_rejected forced_material_id_collision_is_rejected forced_texture_id_collision_is_rejected
S2-RUNTIME-DRAFT-001 covered local testdata/IS and testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
S2-CLI-JSON-001 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order mission_graph_json_has_canonical_field_order
S2-LICENSED-MISSION-P1-001 covered local testdata/IS; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md
index af2e61b..bdb5926 100644
--- a/fixtures/acceptance/stage_0_2_roadmap.md
+++ b/fixtures/acceptance/stage_0_2_roadmap.md
@@ -199,6 +199,7 @@
`S2-ASSET-WEAR-001`
`S2-ASSET-MATERIAL-001`
`S2-ASSET-TEXM-001`
+`S2-ASSET-ID-001`
`S2-RUNTIME-DRAFT-001`
`S2-CLI-JSON-001`
`S2-LICENSED-MISSION-P1-001`