fix: guard prepared asset id collisions

This commit is contained in:
2026-07-03 21:49:42 +04:00
parent f34c422b20
commit 44627cd532
3 changed files with 382 additions and 19 deletions
+380 -19
View File
@@ -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;
@@ -585,16 +585,34 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
repository: &R,
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,
@@ -2004,6 +2192,50 @@ mod tests {
assert_eq!(visual.lightmap_ids.len(), 1);
}
#[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 {
@@ -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 {
+1
View File
@@ -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
1 # Acceptance coverage manifest.
214 S2-ASSET-WEAR-001
215 S2-ASSET-MATERIAL-001
216 S2-ASSET-TEXM-001
217 S2-ASSET-ID-001
218 S2-RUNTIME-DRAFT-001
219 S2-CLI-JSON-001
220 S2-LICENSED-MISSION-P1-001
+1
View File
@@ -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`