diff --git a/Cargo.lock b/Cargo.lock index d7ec227..c3e81d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4317,9 +4317,6 @@ dependencies = [ [[package]] name = "master_protocol" version = "0.1.0" -dependencies = [ - "quinn-proto", -] [[package]] name = "matchers" @@ -5679,6 +5676,7 @@ dependencies = [ "hud_iw4", "lighting_iw4", "marks_iw4", + "master_protocol", "math_iw4", "naga", "net", @@ -5747,6 +5745,7 @@ dependencies = [ "render_backend", "render_frame", "render_material", + "render_scene", ] [[package]] diff --git a/crates/asset_game/src/fpv_assembly.rs b/crates/asset_game/src/fpv_assembly.rs index 6d920b8..0ca55fc 100644 --- a/crates/asset_game/src/fpv_assembly.rs +++ b/crates/asset_game/src/fpv_assembly.rs @@ -123,6 +123,7 @@ pub struct FpvAssemblyKey { #[derive(Clone, Debug)] pub struct FpvSideAssemblies { + pub ammunition: asset_model::AmmunitionVisualMode, pub bare: Arc, pub rocket: Option>, pub melee: Option>, diff --git a/crates/asset_game/src/impact_fx_catalog.rs b/crates/asset_game/src/impact_fx_catalog.rs index 3338503..d5a25d6 100644 --- a/crates/asset_game/src/impact_fx_catalog.rs +++ b/crates/asset_game/src/impact_fx_catalog.rs @@ -10,9 +10,9 @@ pub struct OwnedFxImpactEntry { pub flesh: [String; FX_IMPACT_FLESH_COUNT], } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct OwnedFxImpactTable { - pub t5: bool, + pub namespace: crate::AssetNamespace, pub name: String, pub entries: Vec, pub capture_gaps: usize, @@ -28,11 +28,7 @@ impl OwnedFxImpactTable { } pub fn namespace(&self) -> crate::AssetNamespace { - if self.t5 { - crate::AssetNamespace::T5 - } else { - crate::AssetNamespace::Iw4 - } + self.namespace } pub fn effect_name( @@ -105,7 +101,7 @@ impl ImpactFxCatalog { } self.capture_gaps += gaps; self.table = Some(OwnedFxImpactTable { - t5: false, + namespace: crate::AssetNamespace::Iw4, name, entries, capture_gaps: gaps, @@ -164,7 +160,7 @@ impl ImpactFxCatalog { } self.capture_gaps += gaps; self.table = Some(OwnedFxImpactTable { - t5: true, + namespace: crate::AssetNamespace::T5, name: s.cstr(name)?.to_owned(), entries: rows, capture_gaps: gaps, @@ -175,7 +171,7 @@ impl ImpactFxCatalog { impl OwnedFxImpactTable { pub fn impact_row(&self, impact_type: i32, exit: bool) -> Option { - if !self.t5 { + if self.namespace == crate::AssetNamespace::Iw4 { return fx_iw4::impact_table_row(impact_type, exit); } let exit = usize::from(exit); diff --git a/crates/asset_game/src/menu_catalog.rs b/crates/asset_game/src/menu_catalog.rs index 52e28b4..988c322 100644 --- a/crates/asset_game/src/menu_catalog.rs +++ b/crates/asset_game/src/menu_catalog.rs @@ -60,6 +60,12 @@ pub enum MenuEvent { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)] +pub struct ExpressionId { + pub source: u64, + pub statement: (u8, u32), +} + #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub struct MenuHandlers { @@ -113,6 +119,7 @@ pub struct MenuSlider { #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub struct MenuItem { + pub authored_id: String, pub name: String, pub handlers: ItemHandlers, @@ -149,6 +156,8 @@ pub struct MenuItem { pub vis_ptr: u8, pub mat_ptr: u8, pub vis_exp: String, + pub visibility_expression: Option, + pub expression_ids: std::collections::BTreeMap, pub text_exp: String, pub material_exp: String, pub disabled_exp: String, @@ -197,17 +206,6 @@ pub struct MenuDef { } impl MenuDef { - pub fn inherit_shared_vis(&mut self) { - let mut prev = String::new(); - for item in &mut self.items { - if item.vis_exp.is_empty() && item.vis_ptr != 0 && !prev.is_empty() { - item.vis_exp = prev.clone(); - } else if !item.vis_exp.is_empty() { - prev = item.vis_exp.clone(); - } - } - } - pub fn static_dvar_name(&self, index: i32) -> Option<&str> { self.expr_dvar_rows .get_or_init(|| expr_dvar_rows(&self.expr_dvars)) @@ -345,8 +343,12 @@ impl CapturedStringTable { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImageProductRevision(pub u64); + #[derive(Clone, Debug, Default, Resource)] pub struct MenuCatalog { + image_revision: std::sync::OnceLock, pub menus: BTreeMap, pub fonts: BTreeMap, @@ -354,14 +356,14 @@ pub struct MenuCatalog { pub rawfiles: BTreeMap, - pub zone_images: BTreeMap, + pub(crate) zone_images: BTreeMap, - pub material_state_bits: BTreeMap>, + pub(crate) material_state_bits: BTreeMap>, - pub material_2d_plans: BTreeMap, + pub(crate) material_2d_plans: BTreeMap, - pub material_images: BTreeMap, - pub material_srgb_reads: BTreeMap, + pub(crate) material_images: BTreeMap, + pub(crate) material_srgb_reads: BTreeMap, pub lists: Vec<(String, i32)>, pub walked: usize, @@ -390,16 +392,64 @@ pub struct HudMaterialTextureBinding { } impl MenuCatalog { - /// Runs after every variant is resolved: a variant that overrides an item's - /// visibility must still pass it on to the items that share it. - pub fn inherit_shared_vis(&mut self) { - for def in self.menus.values_mut() { - def.inherit_shared_vis(); - } - } - pub fn load_definitions(&mut self, source: &str) -> Result<(), String> { - let definitions = crate::menu_source::load(source, self)?; + let mut definitions = crate::menu_source::load(source, self)?; + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + source.hash(&mut hash); + let source = hash.finish(); + let mut next = 0_u32; + for definition in &mut definitions { + let mut ids = std::collections::BTreeSet::new(); + for item in &definition.items { + if !item.authored_id.is_empty() + && !ids.insert(item.authored_id.to_ascii_lowercase()) + { + return Err(format!( + "Duplicate authored item identity in {}", + definition.name + )); + } + } + for item in &mut definition.items { + item.expression_ids.clear(); + for (field, dump) in [ + ("Visible", &item.vis_exp), + ("Disabled", &item.disabled_exp), + ("Text", &item.text_exp), + ("Material", &item.material_exp), + ] { + if !dump.is_empty() { + item.expression_ids.insert( + field.to_owned(), + ExpressionId { + source, + statement: (u8::MAX, next), + }, + ); + next = next + .checked_add(1) + .ok_or("authored expression identity exhausted")?; + } + } + for (key, dump) in &item.float_exp { + if !dump.is_empty() { + item.expression_ids.insert( + format!("Float({key})"), + ExpressionId { + source, + statement: (u8::MAX, next), + }, + ); + next = next + .checked_add(1) + .ok_or("authored expression identity exhausted")?; + } + } + item.visibility_expression = item.expression_ids.get("Visible").copied(); + } + } + self.menus .extend(definitions.into_iter().map(|def| (def.name.clone(), def))); Ok(()) @@ -475,6 +525,34 @@ impl MenuCatalog { .get(&AssetRef::bare_name(name).to_ascii_lowercase()) } + pub fn image_revision(&self) -> Option { + self.image_revision.get().copied() + } + + pub fn publish_image_products(&self) -> ImageProductRevision { + *self + .image_revision + .get_or_init(|| ImageProductRevision(self.image_identity())) + } + + pub fn zone_images(&self) -> &BTreeMap { + &self.zone_images + } + pub fn material_images(&self) -> &BTreeMap { + &self.material_images + } + pub fn material_srgb_reads(&self) -> &BTreeMap { + &self.material_srgb_reads + } + pub fn material_2d_plans(&self) -> &BTreeMap { + &self.material_2d_plans + } + pub fn material_state_bits( + &self, + ) -> &BTreeMap> { + &self.material_state_bits + } + pub fn image_identity(&self) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); @@ -483,7 +561,7 @@ impl MenuCatalog { image.material.hash(&mut hasher); image.image.hash(&mut hasher); (image.width, image.height).hash(&mut hasher); - Arc::as_ptr(&image.rgba).hash(&mut hasher); + image.rgba.as_slice().hash(&mut hasher); } self.material_images.hash(&mut hasher); self.material_srgb_reads.hash(&mut hasher); @@ -529,6 +607,14 @@ impl MenuCatalog { } pub fn absorb(&mut self, other: MenuCatalog) { + let images_changed = !other.zone_images.is_empty() + || !other.material_images.is_empty() + || !other.material_srgb_reads.is_empty() + || !other.material_2d_plans.is_empty() + || !other.material_state_bits.is_empty(); + if images_changed { + self.image_revision = std::sync::OnceLock::new(); + } self.walked += other.walked; self.font_headers += other.font_headers; self.lists.extend(other.lists); @@ -553,6 +639,9 @@ impl MenuCatalog { for (name, image) in other.zone_images { self.zone_images.insert(name, image); } + if images_changed { + self.publish_image_products(); + } } } @@ -660,7 +749,11 @@ fn load_menu_catalog_with_iwd(path: &Path, games: Option<&Path>) -> Result) -> Result format!("asset #{index} {} at {slot:?}: {error:?}", ty.name()), None => format!("asset table: {error:?}"), })?; - Ok(sink.finish_and_take(games)) + let catalog = sink.finish_and_take(games); + catalog.publish_image_products(); + Ok(catalog) } #[derive(Clone, Copy)] @@ -713,6 +808,7 @@ enum RecordedEvent { } struct MenuSink { + expression_source: u64, russian_fonts: bool, catalog: MenuCatalog, names: HashMap<(u8, u32), String>, @@ -1377,6 +1473,52 @@ impl AssetLinkSink for MenuSink { Ok(()) } + fn capture_item_expression_reference( + &mut self, + menu: &str, + _item: &str, + field: fastfile_iw4::ItemExpressionField, + source: Ptr, + ) -> fastfile_iw4::Result<()> { + if let Some(item) = self + .catalog + .menus + .get_mut(menu) + .and_then(|menu| menu.items.last_mut()) + { + item.expression_ids.insert( + format!("{field:?}"), + ExpressionId { + source: self.expression_source, + statement: ptr_key(source), + }, + ); + } + Ok(()) + } + + fn capture_item_visible_reference( + &mut self, + menu: &str, + item: &str, + source: Ptr, + dump: &str, + ) -> fastfile_iw4::Result<()> { + self.capture_item_visible_exp(menu, item, dump)?; + if let Some(item) = self + .catalog + .menus + .get_mut(menu) + .and_then(|menu| menu.items.last_mut()) + { + item.visibility_expression = Some(ExpressionId { + source: self.expression_source, + statement: ptr_key(source), + }); + } + Ok(()) + } + fn capture_item_visible_exp( &mut self, menu: &str, diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index e35c38e..d3f0485 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -31,7 +31,9 @@ mod capture_t6; pub use capture_t5::t5_inline_note_alias; use capture_t6::*; mod capture_t5; +mod flame_stream; use capture_t5::*; +pub use flame_stream::{FlameStreamView, FlameTube, WeaponFlameStream}; mod capture_iw5; use capture_iw5::*; mod binding; @@ -326,6 +328,7 @@ pub(crate) struct WeaponBodyFacts { pub min_player_damage: i32, pub max_damage_range: f32, pub min_damage_range: f32, + pub flame_damage_interval_ms: i32, pub kick: WeaponKickFacts, @@ -625,6 +628,8 @@ struct CatalogWeapon { pub combat_fx: WeaponCombatFx, + pub flame: Option>, + pub(crate) combat_slots: CombatFxSlots, pub facts: WeaponBodyFacts, } @@ -836,6 +841,9 @@ pub struct WeaponSoundAliases { pub fire_player_akimbo: Option, + pub fire_start: Option, + pub fire_start_player: Option, + pub fire_loop: Option, pub fire_loop_player: Option, @@ -923,6 +931,12 @@ weapon_sound_slots! { ProjIgnition => proj_ignition_sound, FireLast => fire_last, FireLastPlayer => fire_last_player, + FireStart => fire_start, + FireStartPlayer => fire_start_player, + FireLoop => fire_loop, + FireLoopPlayer => fire_loop_player, + FireStop => fire_stop, + FireStopPlayer => fire_stop_player, } impl WeaponSoundAliases { @@ -967,6 +981,8 @@ impl WeaponSoundAliases { self.projectile.as_deref(), self.proj_ignition_sound.as_deref(), self.fire_player_akimbo.as_deref(), + self.fire_start.as_deref(), + self.fire_start_player.as_deref(), self.fire_loop.as_deref(), self.fire_loop_player.as_deref(), self.fire_stop.as_deref(), @@ -1428,6 +1444,8 @@ struct WeaponRow { combat_fx: WeaponCombatFx, + flame: Option>, + reticle: WeaponReticleAssets, hud_material_edges: WeaponHudMaterialEdges, @@ -1533,6 +1551,7 @@ impl Default for WeaponRow { iw5_notetrack_overrides: Vec::new(), sounds: WeaponSoundAliases::default(), combat_fx: WeaponCombatFx::empty(crate::AssetNamespace::Iw4), + flame: None, reticle: WeaponReticleAssets::default(), hud_material_edges: WeaponHudMaterialEdges::default(), overlay_material: None, diff --git a/crates/asset_game/src/weapon_catalog/capture_iw4.rs b/crates/asset_game/src/weapon_catalog/capture_iw4.rs index cf51c38..3f3b7ac 100644 --- a/crates/asset_game/src/weapon_catalog/capture_iw4.rs +++ b/crates/asset_game/src/weapon_catalog/capture_iw4.rs @@ -279,6 +279,8 @@ impl WeaponCatalog { geometry.notetrack_rumble_values, ), fire_player_akimbo: None, + fire_start: None, + fire_start_player: None, fire_loop: None, fire_loop_player: None, fire_stop: None, @@ -304,6 +306,7 @@ impl WeaponCatalog { explosion: geometry.explosion_slot, tracer: geometry.tracer_slot, }, + flame: None, combat_fx: WeaponCombatFx { last_shot_eject_pair_authored: geometry.view_last_shot_eject_slot.is_some() && geometry.world_last_shot_eject_slot.is_some(), @@ -464,6 +467,7 @@ impl WeaponCatalog { min_player_damage: geometry.min_player_damage, max_damage_range: geometry.max_damage_range, min_damage_range: geometry.min_damage_range, + flame_damage_interval_ms: 0, kick: WeaponKickFacts::from_capture(geometry.kick), sway: WeaponSwayFacts::from_capture(geometry.sway), dual_wield_view_model_offset: geometry.dual_wield_view_model_offset, diff --git a/crates/asset_game/src/weapon_catalog/capture_iw5.rs b/crates/asset_game/src/weapon_catalog/capture_iw5.rs index cdfb239..5257c82 100644 --- a/crates/asset_game/src/weapon_catalog/capture_iw5.rs +++ b/crates/asset_game/src/weapon_catalog/capture_iw5.rs @@ -269,6 +269,7 @@ impl WeaponCatalog { sz_xanims_left: [const { None }; WEAPON_ANIM_SLOTS], hide_tags: read_hide_tags_iw5(stream, strings, geometry.hide_tags), sounds: leftover_iw5_sounds(stream, strings, &geometry), + flame: None, combat_fx: read_iw5_combat_fx(stream, &geometry, fx_name_at_slot), combat_slots: CombatFxSlots::default(), facts: capture_iw5_body_facts(stream, &geometry), @@ -839,6 +840,8 @@ pub(super) fn leftover_iw5_sounds( }; let mut sounds = WeaponSoundAliases { notetrack_convention: NotetrackConvention::SoundMap, + fire_start: None, + fire_start_player: None, pickup: leftover_iw5_snd_alias(stream, body, sz::WEAPON_DEF_SND_PICKUP_OFF, 128), pickup_player: leftover_iw5_snd_alias( stream, diff --git a/crates/asset_game/src/weapon_catalog/capture_merge.rs b/crates/asset_game/src/weapon_catalog/capture_merge.rs index 9576d60..dd76bed 100644 --- a/crates/asset_game/src/weapon_catalog/capture_merge.rs +++ b/crates/asset_game/src/weapon_catalog/capture_merge.rs @@ -207,6 +207,8 @@ pub(super) fn merge_sound_aliases(dst: &mut WeaponSoundAliases, src: &WeaponSoun (&mut dst.projectile, &src.projectile), (&mut dst.proj_ignition_sound, &src.proj_ignition_sound), (&mut dst.fire_player_akimbo, &src.fire_player_akimbo), + (&mut dst.fire_start, &src.fire_start), + (&mut dst.fire_start_player, &src.fire_start_player), (&mut dst.fire_loop, &src.fire_loop), (&mut dst.fire_loop_player, &src.fire_loop_player), (&mut dst.fire_stop, &src.fire_stop), @@ -680,6 +682,9 @@ pub(super) fn merge_body_facts(dst: &mut WeaponBodyFacts, src: WeaponBodyFacts) if dst.min_damage_range == 0.0 { dst.min_damage_range = src.min_damage_range; } + if dst.flame_damage_interval_ms == 0 { + dst.flame_damage_interval_ms = src.flame_damage_interval_ms; + } if !dst.inherits_perks && src.inherits_perks { dst.inherits_perks = true; } diff --git a/crates/asset_game/src/weapon_catalog/capture_t5.rs b/crates/asset_game/src/weapon_catalog/capture_t5.rs index 5633f36..da0e561 100644 --- a/crates/asset_game/src/weapon_catalog/capture_t5.rs +++ b/crates/asset_game/src/weapon_catalog/capture_t5.rs @@ -185,6 +185,7 @@ impl WeaponCatalog { hide_tags: read_hide_tags_t5(stream, strings, geometry.hide_tags), sounds: leftover_t5_sounds(stream, strings, &geometry), combat_fx: WeaponCombatFx::empty(crate::AssetNamespace::T5), + flame: flame_stream::capture_t5_flame_stream(stream, &geometry), combat_slots: CombatFxSlots::default(), facts: capture_t5_body_facts(stream, &geometry), }); @@ -385,6 +386,46 @@ pub(super) fn leftover_t5_cstr( .map(str::to_owned) } +fn leftover_t5_flame_table( + stream: &fastfile_t5::ZoneStream<'_>, + body: fastfile_t5::Ptr, + off: usize, +) -> Option { + match stream.ptr_at(body, off).ok()? { + fastfile_t5::ZonePtr::Offset(q) => Some(stream.resolve_alias(q)), + _ => None, + } +} + +fn apply_t5_flame( + stream: &fastfile_t5::ZoneStream<'_>, + body: fastfile_t5::Ptr, + facts: &mut WeaponBodyFacts, +) { + use fastfile_t5::size as sz; + let fuel_tank = u8_at_t5(stream, body, sz::WEAPON_DEF_FUEL_TANK_WEAPON_OFF) != 0; + let tank_life_ms = i32_at_t5(stream, body, sz::WEAPON_DEF_TANK_LIFE_TIME_OFF); + if fuel_tank && tank_life_ms > 0 && facts.fire_time_ms > 0 { + let ticks = (tank_life_ms / facts.fire_time_ms).max(1); + facts.clip_size = facts.clip_size.saturating_mul(ticks); + if !facts.ammo_count_clip_relative { + facts.start_ammo = facts.start_ammo.saturating_mul(ticks); + facts.max_ammo = facts.max_ammo.saturating_mul(ticks); + } + } + facts.flame_damage_interval_ms = + f32_at_t5(stream, body, sz::WEAPON_DEF_DAMAGE_INTERVAL_OFF) as i32; + let reach = leftover_t5_flame_table(stream, body, sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF) + .map(|table| { + f32_at_t5(stream, table, sz::FLAME_TABLE_STREAM_FUEL_LENGTH_OFF) + + f32_at_t5(stream, table, sz::FLAME_TABLE_STREAM_FLAME_LENGTH_OFF) + }) + .unwrap_or(0.0); + facts.min_damage = facts.damage; + facts.max_damage_range = reach; + facts.min_damage_range = reach; +} + pub(super) fn leftover_t5_asset_slot( stream: &fastfile_t5::ZoneStream<'_>, body: Option, @@ -595,11 +636,40 @@ pub(super) fn leftover_t5_sounds( let Some(body) = geometry.weap_def else { return WeaponSoundAliases::default(); }; + let gas = geometry.weap_type == weapon_iw4::WEAPTYPE_GAS; + let flame_sound = |table_off, sound_off| { + let table = gas.then(|| leftover_t5_flame_table(stream, body, table_off))??; + leftover_t5_cstr(stream, table, sound_off) + }; WeaponSoundAliases { projectile: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJECTILE_SOUND_OFF), proj_explosion: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJ_EXPLOSION_SOUND_OFF), proj_ignition_sound: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJ_IGNITION_SOUND_OFF), notetrack_convention: NotetrackConvention::InlinePrefix, + fire_start: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF, + sz::FLAME_TABLE_IGNITE_SOUND_OFF, + ), + fire_start_player: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF, + sz::FLAME_TABLE_IGNITE_SOUND_OFF, + ), + fire_loop: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF, + sz::FLAME_TABLE_ON_LOOP_SOUND_OFF, + ), + fire_loop_player: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF, + sz::FLAME_TABLE_ON_LOOP_SOUND_OFF, + ), + fire_stop: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF, + sz::FLAME_TABLE_COOLDOWN_SOUND_OFF, + ), + fire_stop_player: flame_sound( + sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF, + sz::FLAME_TABLE_COOLDOWN_SOUND_OFF, + ), fire: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_FIRE_OFF), fire_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_FIRE_PLAYER_OFF), empty_fire: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_EMPTY_FIRE_OFF), @@ -622,6 +692,8 @@ pub(super) fn leftover_t5_sounds( ), reload_end: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RELOAD_END_OFF), reload_end_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RELOAD_END_PLAYER_OFF), + alt_switch: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_ALT_SWITCH_OFF), + alt_switch_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_ALT_SWITCH_PLAYER_OFF), raise_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RAISE_PLAYER_OFF), putaway_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_PUTAWAY_PLAYER_OFF), melee_swipe: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_MELEE_SWIPE_OFF), @@ -853,6 +925,9 @@ pub(super) fn capture_t5_body_facts( }; facts.inherits_perks = leftover_t5_inherits_host_perks(); facts.kick = leftover_t5_kick(stream, geometry); + if facts.weap_type == weapon_iw4::WEAPTYPE_GAS { + apply_t5_flame(stream, body, &mut facts); + } facts } diff --git a/crates/asset_game/src/weapon_catalog/capture_t6.rs b/crates/asset_game/src/weapon_catalog/capture_t6.rs index c9d6d96..233ad83 100644 --- a/crates/asset_game/src/weapon_catalog/capture_t6.rs +++ b/crates/asset_game/src/weapon_catalog/capture_t6.rs @@ -117,7 +117,14 @@ impl WeaponCatalog { sz_xanims_left: t6_sz_xanims_left(weapon), hide_tags: Vec::new(), sounds: capture_t6_sounds(weapon), + flame: None, combat_fx: WeaponCombatFx { + view_flash_hint: weapon + .def_asset_name(fastfile_t6::weapon::def::VIEW_FLASH_EFFECT) + .map(str::to_owned), + world_flash_hint: weapon + .def_asset_name(fastfile_t6::weapon::def::WORLD_FLASH_EFFECT) + .map(str::to_owned), explosion_hint: weapon .def_asset_name(fastfile_t6::weapon::def::PROJ_EXPLOSION_EFFECT) .map(str::to_owned), @@ -424,6 +431,8 @@ pub(super) fn capture_t6_sounds(w: fastfile_t6::weapon::WeaponView<'_>) -> Weapo melee_miss: name(d::MELEE_MISS_SOUND), pullback: name(d::PULLBACK_SOUND), pullback_player: name(d::PULLBACK_SOUND_PLAYER), + alt_switch: name(d::ALT_SWITCH_SOUND), + alt_switch_player: name(d::ALT_SWITCH_SOUND_PLAYER), raise: name(d::RAISE_SOUND), raise_player: name(d::RAISE_SOUND_PLAYER), first_raise: name(d::FIRST_RAISE_SOUND), diff --git a/crates/asset_game/src/weapon_catalog/combat.rs b/crates/asset_game/src/weapon_catalog/combat.rs index 8b7c8ef..4d5c371 100644 --- a/crates/asset_game/src/weapon_catalog/combat.rs +++ b/crates/asset_game/src/weapon_catalog/combat.rs @@ -79,6 +79,7 @@ fn captured_input(f: WeaponBodyFacts, melee_charge_anim: bool) -> CapturedCombat stunned_start_time_ms: f.stunned_start_time_ms, stunned_end_time_ms: f.stunned_end_time_ms, damage: f.damage, + flame_damage_interval_ms: f.flame_damage_interval_ms, min_damage: f.min_damage, max_damage_range: f.max_damage_range, min_damage_range: f.min_damage_range, diff --git a/crates/asset_game/src/weapon_catalog/flame_stream.rs b/crates/asset_game/src/weapon_catalog/flame_stream.rs new file mode 100644 index 0000000..a3459c5 --- /dev/null +++ b/crates/asset_game/src/weapon_catalog/flame_stream.rs @@ -0,0 +1,122 @@ +use super::capture_t5::f32_at_t5; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FlameTube { + pub size: [f32; 2], + pub length: f32, + pub segments: f32, + pub anim_loop_s: f32, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FlameStreamView { + pub chunk_gravity: [f32; 2], + pub chunk_max_size: f32, + pub chunk_size: [f32; 2], + pub chunk_size_rand: [f32; 2], + pub chunk_dist_scalar: f32, + pub chunk_speed: f32, + pub chunk_decel: f32, + pub chunk_life_s: f32, + pub chunk_rot_rand: f32, + pub fire_interval_s: [f32; 2], + pub fire_first_life_frac: f32, + pub fire_min_life_frac: f32, + pub fire_min_life_age_s: [f32; 2], + pub fuel: FlameTube, + pub flame: FlameTube, + pub fire_life_s: f32, + pub fire_life_rand_s: f32, + pub fire_speed_scale: f32, + pub fire_speed_scale_rand: f32, + pub fire_vel_add_z: f32, + pub fire_vel_add_z_rand: f32, + pub fire_vel_add_sideways: f32, + pub fire_gravity: [f32; 2], + pub fire_max_rot_vel: f32, + pub fire_friction: f32, + pub fire_end_size_add: f32, + pub fire_start_size_scale: f32, + pub fire_end_size_scale: f32, + /// `FlameTable` material order: fire, smoke, heat, drips, fuel, fuel2, + /// flame, flame2. + pub materials: [Option; fastfile_t5::FLAME_TABLE_MATERIAL_COUNT], +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WeaponFlameStream { + pub views: [FlameStreamView; 2], +} + +impl WeaponFlameStream { + pub fn view(&self, player_view: bool) -> &FlameStreamView { + &self.views[usize::from(!player_view)] + } +} + +pub(super) fn capture_t5_flame_stream( + stream: &fastfile_t5::ZoneStream<'_>, + geometry: &fastfile_t5::WeaponGeometry, +) -> Option> { + use fastfile_t5::size as sz; + let body = geometry.weap_def?; + let mut views: [FlameStreamView; 2] = Default::default(); + for (view, (off, names)) in views.iter_mut().zip( + [ + sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF, + sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF, + ] + .into_iter() + .zip(&geometry.flame_material_names), + ) { + let table = match stream.ptr_at(body, off).ok()? { + fastfile_t5::ZonePtr::Offset(q) => stream.resolve_alias(q), + _ => return None, + }; + let f = |i: usize| f32_at_t5(stream, table, i * 4); + let tube = |i: usize| FlameTube { + size: [f(i), f(i + 1)], + length: f(i + 2), + segments: f(i + 3), + anim_loop_s: f(i + 4), + }; + let name = |slot: usize| { + names[slot] + .and_then(|ptr| stream.cstr(ptr).ok()) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + }; + *view = FlameStreamView { + chunk_gravity: [f(0), f(1)], + chunk_max_size: f(2), + chunk_size: [f(3), f(4)], + chunk_size_rand: [f(5), f(6)], + chunk_dist_scalar: f(7), + chunk_speed: f(10), + chunk_decel: f(11), + chunk_life_s: f(13), + chunk_rot_rand: f(28), + fire_interval_s: [f(18), f(19)], + fire_first_life_frac: f(20), + fire_min_life_frac: f(22), + fire_min_life_age_s: [f(23), f(24)], + fuel: tube(40), + flame: tube(45), + fire_life_s: f(58), + fire_life_rand_s: f(59), + fire_speed_scale: f(60), + fire_speed_scale_rand: f(61), + fire_vel_add_z: f(62), + fire_vel_add_z_rand: f(63), + fire_vel_add_sideways: f(64), + fire_gravity: [f(65), f(66)], + fire_max_rot_vel: f(67), + fire_friction: f(68), + fire_end_size_add: f(69), + fire_start_size_scale: f(70), + fire_end_size_scale: f(71), + materials: std::array::from_fn(name), + }; + } + Some(Box::new(WeaponFlameStream { views })) +} diff --git a/crates/asset_game/src/weapon_catalog/model_linking.rs b/crates/asset_game/src/weapon_catalog/model_linking.rs index c2aaefd..60fa656 100644 --- a/crates/asset_game/src/weapon_catalog/model_linking.rs +++ b/crates/asset_game/src/weapon_catalog/model_linking.rs @@ -188,7 +188,7 @@ impl WeaponBuild { if parent == 0 { row.fpv_assemblies = [None, None]; } - let Some(Ok(mounts)) = &row.fpv_mount_plan else { + let Some(Ok(mounts)) = &mut row.fpv_mount_plan else { continue; }; let clips: std::collections::BTreeSet = row @@ -199,11 +199,19 @@ impl WeaponBuild { .filter_map(|edge| edge.bound_index()) .collect(); let hide_mode = row.preparation.hide_mode(); - let embedded_ammo = mounts.rocket.is_none() + let declares_embedded = row.rocket_model.is_none() && row.projectile_model.is_some() && hide_tags .iter() .any(|tag| tag.eq_ignore_ascii_case("tag_clip")); + if declares_embedded && let Err(error) = mounts.prepare_embedded_ammunition(fpv) { + row.fpv_assemblies = std::array::from_fn(|_| Some(Err(error.to_string()))); + continue; + } + let embedded_ammo = matches!( + mounts.ammunition, + asset_model::AmmunitionVisualMode::Embedded { .. } + ); let knife_model = row.knife_xmodel.as_deref().map(|name| { row.preparation .bind_fpv(WeaponComponent::ViewModel, Some(name), fpv) @@ -320,6 +328,7 @@ impl WeaponBuild { .any(|(jammed, bare)| jammed.hide != bare.hide) .then_some(jammed); Some(Ok(crate::FpvSideAssemblies { + ammunition: mounts.ammunition, bare, rocket, melee, diff --git a/crates/asset_game/src/weapon_catalog/publication.rs b/crates/asset_game/src/weapon_catalog/publication.rs index ee7ebb6..3e1b0f9 100644 --- a/crates/asset_game/src/weapon_catalog/publication.rs +++ b/crates/asset_game/src/weapon_catalog/publication.rs @@ -328,6 +328,9 @@ impl WeaponBuild { entry.sounds.leftover_sound_overrides.clone(); } merge_combat_fx(&mut existing.combat_fx, &entry.combat_fx); + if existing.flame.is_none() { + existing.flame = entry.flame; + } merge_combat_slots(&mut existing.combat_slots, &entry.combat_slots); merge_body_facts(&mut existing.facts, entry.facts); } @@ -430,6 +433,7 @@ impl WeaponBuild { iw5_notetrack_overrides: entry.iw5_notetrack_overrides, sounds: entry.sounds, combat_fx: entry.combat_fx, + flame: entry.flame, reticle: entry.reticle, hud_material_edges: entry.hud_material_edges, overlay_material_from_slot: entry.overlay_material_slot.is_some(), diff --git a/crates/asset_game/src/weapon_catalog/registry.rs b/crates/asset_game/src/weapon_catalog/registry.rs index 5d67798..348a251 100644 --- a/crates/asset_game/src/weapon_catalog/registry.rs +++ b/crates/asset_game/src/weapon_catalog/registry.rs @@ -1106,6 +1106,10 @@ impl WeaponRegistry { self.rows.get(index as usize).map(|row| &row.combat_fx) } + pub fn flame_stream_of(&self, index: u32) -> Option<&WeaponFlameStream> { + self.rows.get(index as usize)?.flame.as_deref() + } + pub fn tracer_type_census(&self) -> AssetEdgeCensus { let mut census = AssetEdgeCensus::default(); for row in self.rows.iter().skip(1) { diff --git a/crates/asset_material/src/material_catalog.rs b/crates/asset_material/src/material_catalog.rs index 1eee3bd..1a97233 100644 --- a/crates/asset_material/src/material_catalog.rs +++ b/crates/asset_material/src/material_catalog.rs @@ -2044,7 +2044,7 @@ impl MaterialCatalog { sort_key: geometry.sort_key, info_game_flags: geometry.info_game_flags, - texture_atlas: None, + texture_atlas: Some(geometry.texture_atlas), surface_type_bits: Some(geometry.surface_type_bits), t5_layered_surface_types: Some(geometry.layered_surface_types), state_flags: geometry.state_flags, diff --git a/crates/asset_material/src/material_compile/mod.rs b/crates/asset_material/src/material_compile/mod.rs index 0f373de..5eec8e1 100644 --- a/crates/asset_material/src/material_compile/mod.rs +++ b/crates/asset_material/src/material_compile/mod.rs @@ -137,6 +137,7 @@ pub fn compile_material_catalog(source: &crate::MaterialDefinitions) -> RuntimeM .draw_rules(material, source.is_unlit(material).unwrap_or(false)), sort_key: material.sort_key, info_game_flags: material.info_game_flags, + texture_atlas: material.texture_atlas.unwrap_or([1, 1]), state_flags: material.state_flags, surface_type_bits: material.surface_type_bits, unlit: source.is_unlit(material).unwrap_or(false), diff --git a/crates/asset_model/src/body_catalog.rs b/crates/asset_model/src/body_catalog.rs index 4b2c8da..eb17a5a 100644 --- a/crates/asset_model/src/body_catalog.rs +++ b/crates/asset_model/src/body_catalog.rs @@ -80,6 +80,7 @@ impl BodyMeshEntry { #[derive(Clone, Debug, Default)] pub struct BodyMeshCatalog { + identity: u64, entries: HashMap, kits_cache: std::sync::OnceLock, } @@ -99,7 +100,15 @@ impl std::ops::Deref for BodyMeshBuild { } impl BodyMeshBuild { - pub fn publish(self) -> BodyMeshCatalog { + pub fn publish(mut self) -> BodyMeshCatalog { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + self.catalog.identity = NEXT + .fetch_update( + std::sync::atomic::Ordering::Relaxed, + std::sync::atomic::Ordering::Relaxed, + |next| next.checked_add(1), + ) + .expect("body product revision exhausted"); self.catalog } @@ -193,6 +202,10 @@ impl BodyMeshBuild { } impl BodyMeshCatalog { + pub fn identity(&self) -> u64 { + self.identity + } + pub fn len(&self) -> usize { self.entries.len() } diff --git a/crates/asset_model/src/fpv_catalog.rs b/crates/asset_model/src/fpv_catalog.rs index cbb8e32..862a274 100644 --- a/crates/asset_model/src/fpv_catalog.rs +++ b/crates/asset_model/src/fpv_catalog.rs @@ -561,16 +561,104 @@ pub struct FpvMount { pub tag: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AmmunitionVisualMode { + Absent, + Embedded { bone: u16 }, + Mounted, +} + #[derive(Clone, Debug)] pub struct FpvMountPlan { pub gun: FpvMeshIndex, pub secondary_gun: Option, pub attachments: Vec, pub rocket: Option, + pub ammunition: AmmunitionVisualMode, pub ads_swaps: Vec<(usize, FpvMeshIndex)>, } impl FpvMountPlan { + pub fn prepare_embedded_ammunition( + &mut self, + catalog: &FpvMeshCatalog, + ) -> Result<(), FpvMountError> { + let entry = catalog + .get_at(self.gun.order()) + .ok_or_else(|| FpvMountError { + model: format!("#{}", self.gun.order()), + detail: "gun missing from FPV catalog", + })?; + let valid = entry.skel.pose.as_ref().is_some_and(|pose| { + let count = entry.skel.bone_names.len(); + pose.num_bones == count + && entry.skel.bones.len() == count + && pose.num_root_bones > 0 + && pose.num_root_bones <= count + && pose.parent_list.len() == count - pose.num_root_bones + && (pose.num_root_bones..count).all(|bone| entry.skel.parent_of(bone).is_some()) + && entry.skel.vert_skin.iter().all(|skin| { + (0..4) + .filter(|index| { + if *index == 0 { + !skin.primary_is_zero() + } else { + skin.has_extra_weight(*index) + } + }) + .all(|index| usize::from(skin.bones[index]) < count) + }) + }); + if !valid { + return Err(FpvMountError { + model: entry.skel.name.clone(), + detail: "invalid embedded ammunition hierarchy or binding", + }); + } + let bone = entry + .skel + .bone_names + .iter() + .position(|name| name.eq_ignore_ascii_case("tag_clip")) + .filter(|bone| *bone <= u8::MAX as usize) + .filter(|bone| { + entry.skel.vert_skin.iter().any(|skin| { + skin.bones + .iter() + .enumerate() + .filter(|(index, _)| { + if *index == 0 { + !skin.primary_is_zero() + } else { + skin.has_extra_weight(*index) + } + }) + .any(|(_, bound)| { + let mut current = Some(usize::from(*bound)); + while let Some(index) = current { + if index == *bone { + return true; + } + current = entry.skel.parent_of(index); + } + false + }) + }) + }) + .ok_or_else(|| FpvMountError { + model: entry.skel.name.clone(), + detail: "embedded ammunition has no bound geometry", + })?; + if entry.skel.pose.is_none() || self.rocket.is_some() { + return Err(FpvMountError { + model: entry.skel.name.clone(), + detail: "invalid embedded ammunition structure", + }); + } + self.ammunition = AmmunitionVisualMode::Embedded { bone: bone as u16 }; + Ok(()) + } + pub fn attachment_models(&self, ads: bool) -> impl Iterator + '_ { self.attachments.iter().enumerate().map(move |(at, mount)| { ads.then(|| self.ads_swaps.iter().find(|(swap, _)| *swap == at)) @@ -728,6 +816,11 @@ pub fn plan_fpv_mounts( gun, secondary_gun: None, attachments: selected, + ammunition: if rocket.is_some() { + AmmunitionVisualMode::Mounted + } else { + AmmunitionVisualMode::Absent + }, rocket, ads_swaps: Vec::new(), }) diff --git a/crates/asset_model/src/world_weapon_catalog.rs b/crates/asset_model/src/world_weapon_catalog.rs index 622b4d5..b1ce23b 100644 --- a/crates/asset_model/src/world_weapon_catalog.rs +++ b/crates/asset_model/src/world_weapon_catalog.rs @@ -109,12 +109,19 @@ impl WorldWeaponBuild { pub fn seal_identity(&mut self) -> u64 { static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); if self.catalog.identity == 0 { - self.catalog.identity = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.catalog.identity = NEXT_ID + .fetch_update( + std::sync::atomic::Ordering::Relaxed, + std::sync::atomic::Ordering::Relaxed, + |next| next.checked_add(1), + ) + .expect("world weapon product revision exhausted"); } self.catalog.identity } - pub fn publish(self) -> WorldWeaponCatalog { + pub fn publish(mut self) -> WorldWeaponCatalog { + self.seal_identity(); self.catalog } @@ -227,6 +234,7 @@ impl WorldWeaponBuild { } fn retain(&mut self, key: WorldWeaponKey, entry: WorldWeaponEntry) { + self.catalog.identity = 0; if let Some(&pos) = self.catalog.indices.get(&key) { self.catalog.zones[pos] = self.capture_zone; self.catalog.entries[pos] = entry; diff --git a/crates/asset_world/src/clip_collision.rs b/crates/asset_world/src/clip_collision.rs index f111677..28ba080 100644 --- a/crates/asset_world/src/clip_collision.rs +++ b/crates/asset_world/src/clip_collision.rs @@ -28,12 +28,25 @@ pub struct ClipCmodel { #[derive(Clone, Debug, Default)] pub struct ClipMapMaterial { pub name: String, + pub source_surface_flags: u32, pub surface_flags: u32, pub content_flags: u32, } +pub const COLLISION_ADAPTER_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CollisionCoverage { + #[default] + Unvalidated, + Complete, +} + #[derive(Clone, Debug, Default)] pub struct ClipCollision { + pub adapter_version: u32, + pub source_family: Option, + pub coverage: CollisionCoverage, pub brushes: Vec, pub nodes: Vec, @@ -178,6 +191,244 @@ impl clipmap_iw4::BrushView for ClipBrush { } impl ClipCollision { + pub fn validate( + &mut self, + family: asset_core::AssetNamespace, + ) -> Result<(), ClipCollisionError> { + self.coverage = CollisionCoverage::Unvalidated; + let invalid = || ClipCollisionError::InvalidSemantics("BSP node graph"); + let finite = |v: &[f32]| v.iter().all(|n| n.is_finite()); + let mut node_indegree = vec![0_usize; self.nodes.len()]; + for node in &self.nodes { + if !finite(&node.plane) || node.plane[..3].iter().map(|n| n * n).sum::() < 0.5 { + return Err(invalid()); + } + for child in node.children { + if child >= 0 { + let slot = node_indegree.get_mut(child as usize).ok_or_else(invalid)?; + *slot += 1; + } else if (-1_i64 - i64::from(child)) as usize >= self.leaves.len() { + return Err(invalid()); + } + } + } + let mut roots: std::collections::VecDeque<_> = node_indegree + .iter() + .enumerate() + .filter_map(|(index, n)| (*n == 0).then_some(index)) + .collect(); + let mut visited = 0; + while let Some(index) = roots.pop_front() { + visited += 1; + for child in self.nodes[index] + .children + .into_iter() + .filter(|child| *child >= 0) + { + node_indegree[child as usize] -= 1; + if node_indegree[child as usize] == 0 { + roots.push_back(child as usize); + } + } + } + if visited != self.nodes.len() { + return Err(invalid()); + } + let invalid = || ClipCollisionError::InvalidSemantics("brush hull"); + for brush in &self.brushes { + if brush.planes.len() < 4 + || brush.planes.len() != brush.plane_surface_flags.len() + || brush.planes.iter().any(|plane| { + !finite(plane) || plane[..3].iter().map(|n| n * n).sum::() < 0.5 + }) + { + return Err(invalid()); + } + } + let invalid = || ClipCollisionError::InvalidSemantics("leaf brush references"); + if self + .leafbrushes + .iter() + .any(|index| *index as usize >= self.brushes.len()) + { + return Err(invalid()); + } + let invalid = || ClipCollisionError::InvalidSemantics("leaf brush span"); + for leaf in &self.leaves { + if (leaf.first_brush as usize) + .checked_add(leaf.num_brushes as usize) + .is_none_or(|end| end > self.leafbrushes.len()) + { + return Err(invalid()); + } + } + let invalid = || ClipCollisionError::InvalidSemantics("collision model span or bounds"); + for model in &self.cmodels { + if !finite(&model.mins) + || !finite(&model.maxs) + || !model.radius.is_finite() + || model.radius < 0.0 + || (0..3).any(|axis| model.mins[axis] > model.maxs[axis]) + || (model.first_brush as usize) + .checked_add(model.num_brushes as usize) + .is_none_or(|end| end > self.leafbrushes.len()) + { + return Err(invalid()); + } + } + let invalid = || ClipCollisionError::InvalidSemantics("mesh arrays"); + let mesh = &self.mesh; + let triangles = mesh.tri_count(); + if mesh.tri_indices.len() % 3 != 0 { + return Err(ClipCollisionError::InvalidSemantics("triangle triplets")); + } + if mesh.verts.iter().any(|vertex| !finite(vertex)) { + return Err(ClipCollisionError::InvalidSemantics("finite mesh vertices")); + } + if triangles != 0 && mesh.partitions.is_empty() { + return Err(ClipCollisionError::InvalidSemantics( + "mesh partitions absent", + )); + } + if triangles != 0 && mesh.tri_surface_flags.len() != triangles { + return Err(ClipCollisionError::InvalidSemantics( + "triangle surface flags", + )); + } + if triangles != 0 && mesh.tri_content_flags.len() != triangles { + return Err(ClipCollisionError::InvalidSemantics("triangle contents")); + } + if !self.tri_material_index.is_empty() + && (self.tri_material_index.len() != triangles + || self + .tri_material_index + .iter() + .enumerate() + .any(|(tri, index)| { + *index as usize >= self.materials.len() + && mesh + .tri_content_flags + .get(tri) + .is_some_and(|flags| *flags != 0) + })) + { + return Err(invalid()); + } + let invalid = || ClipCollisionError::InvalidSemantics("partition range"); + let mut covered = vec![false; triangles]; + for partition in &mesh.partitions { + let first = usize::try_from(partition.first_tri).map_err(|_| invalid())?; + let end = first + .checked_add(partition.tri_count as usize) + .filter(|end| *end <= triangles) + .ok_or_else(invalid)?; + if (partition.first_border as usize) + .checked_add(partition.border_count as usize) + .is_none_or(|end| end > mesh.borders.len()) + { + return Err(invalid()); + } + covered[first..end].fill(true); + let base = partition.first_vert_segment as usize * clipmap_iw4::VERTS_PER_SEGMENT; + if mesh.tri_indices[first * 3..end * 3] + .iter() + .any(|index| base + *index as usize >= mesh.verts.len()) + { + return Err(invalid()); + } + } + let invalid = || ClipCollisionError::InvalidSemantics("triangle coverage"); + if covered + .iter() + .enumerate() + .any(|(tri, covered)| !covered && mesh.tri_content_flags[tri] != 0) + { + return Err(invalid()); + } + let invalid = || ClipCollisionError::InvalidSemantics("mesh border"); + for border in &mesh.borders { + if !finite(&border.dist_eq) + || !finite(&[border.z_base, border.z_slope, border.start, border.length]) + || border.length < 0.0 + { + return Err(invalid()); + } + } + for leaf in &self.leaves { + if (leaf.first_coll_aabb_index as usize) + .checked_add(leaf.coll_aabb_count as usize) + .is_none_or(|end| end > mesh.aabb_trees.len()) + { + return Err(invalid()); + } + } + let invalid = || ClipCollisionError::InvalidSemantics("AABB roots"); + if mesh + .aabb_roots + .iter() + .any(|index| *index as usize >= mesh.aabb_trees.len()) + { + return Err(invalid()); + } + let invalid = || ClipCollisionError::InvalidSemantics("AABB child or partition reference"); + for node in &mesh.aabb_trees { + if !finite(&node.origin) + || !finite(&node.half_size) + || node.half_size.iter().any(|n| *n < 0.0) + || (node.child_count != 0 + && usize::try_from(node.u) + .ok() + .and_then(|first| first.checked_add(node.child_count as usize)) + .is_none_or(|end| end > mesh.aabb_trees.len())) + || (node.child_count == 0 + && usize::try_from(node.u) + .ok() + .is_none_or(|index| index >= mesh.partitions.len())) + { + return Err(invalid()); + } + } + let mut indegree = vec![0_usize; mesh.aabb_trees.len()]; + for node in &mesh.aabb_trees { + if node.child_count == 0 { + if !self.materials.is_empty() + && node.material_index as usize >= self.materials.len() + { + return Err(invalid()); + } + } else { + for child in node.u as usize..node.u as usize + node.child_count as usize { + indegree[child] += 1; + } + } + } + let mut roots: std::collections::VecDeque<_> = indegree + .iter() + .enumerate() + .filter_map(|(index, n)| (*n == 0).then_some(index)) + .collect(); + let mut visited = 0; + while let Some(index) = roots.pop_front() { + visited += 1; + let node = &mesh.aabb_trees[index]; + if node.child_count != 0 { + for child in node.u as usize..node.u as usize + node.child_count as usize { + indegree[child] -= 1; + if indegree[child] == 0 { + roots.push_back(child); + } + } + } + } + if visited != mesh.aabb_trees.len() { + return Err(ClipCollisionError::InvalidSemantics("AABB cycle")); + } + self.adapter_version = COLLISION_ADAPTER_VERSION; + self.source_family = Some(family); + self.coverage = CollisionCoverage::Complete; + Ok(()) + } + fn map_ref(&self) -> clipmap_iw4::ClipMapRef<'_, ClipBrush> { clipmap_iw4::ClipMapRef { nodes: &self.nodes, @@ -208,6 +459,7 @@ impl ClipCollision { let mesh = self.mesh.as_ref().as_ref(); let trace = clipmap_iw4::trace_brush_and_mesh(&map, &mesh, &ext, &|_piece| true); ClipSweepHit { + coverage: self.coverage, fraction: trace.fraction, normal: trace.normal, endpos: trace.endpos, @@ -220,6 +472,7 @@ impl ClipCollision { #[derive(Clone, Copy, Debug)] pub struct ClipSweepHit { + pub coverage: CollisionCoverage, pub fraction: f32, pub normal: [f32; 3], pub endpos: [f32; 3], @@ -235,11 +488,15 @@ pub enum ClipCollisionError { Truncated, LeafbrushIndexOverflow, + InvalidSemantics(&'static str), } impl core::fmt::Display for ClipCollisionError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { + Self::InvalidSemantics(reason) => { + write!(f, "collision semantics or references are invalid: {reason}") + } Self::MissingTables => write!(f, "clipmap brush tables were not retained"), Self::Truncated => write!(f, "clipmap brush bytes truncated in arena"), Self::LeafbrushIndexOverflow => { @@ -362,6 +619,7 @@ pub fn build_clip_collision( extract_cmodels(s, g, &mut out)?; gate_leafbrush_index_fits(out.leafbrushes.len())?; + out.validate(asset_core::AssetNamespace::Iw4)?; Ok(out) } @@ -372,6 +630,9 @@ pub fn attach_static_models( out: &mut ClipCollision, ) { extract_static_models(s, g, catalog, out); + if out.static_models.len() != g.static_model_count { + out.coverage = CollisionCoverage::Unvalidated; + } } pub fn attach_iw5_static_models( @@ -381,6 +642,9 @@ pub fn attach_iw5_static_models( out: &mut ClipCollision, ) { extract_iw5_static_models(s, g, catalog, out); + if out.static_models.len() != g.static_model_count { + out.coverage = CollisionCoverage::Unvalidated; + } } fn extract_mesh_tables( @@ -389,6 +653,12 @@ fn extract_mesh_tables( out: &mut ClipCollision, ) -> Result<(), ClipCollisionError> { let mesh = std::sync::Arc::make_mut(&mut out.mesh); + if (g.vert_count != 0 && g.verts.is_none()) + || (g.tri_count != 0 && g.tri_indices.is_none()) + || (g.material_count != 0 && g.materials.is_none()) + { + return Err(ClipCollisionError::MissingTables); + } let Some(verts_ptr) = g.verts else { return Ok(()); }; @@ -548,7 +818,9 @@ fn extract_clip_materials_iw4( ) -> Result, ClipCollisionError> { let mut out = Vec::with_capacity(g.material_count); let Some(mats) = g.materials else { - out.resize(g.material_count, ClipMapMaterial::default()); + if g.material_count != 0 { + return Err(ClipCollisionError::MissingTables); + } return Ok(out); }; for i in 0..g.material_count { @@ -568,6 +840,7 @@ fn extract_clip_materials_iw4( .map_err(|_| ClipCollisionError::Truncated)?; out.push(ClipMapMaterial { name, + source_surface_flags: surface_flags, surface_flags, content_flags, }); @@ -1160,6 +1433,7 @@ pub fn build_iw5_clip_collision( extract_iw5_cmodels(s, g, &mut out)?; gate_leafbrush_index_fits(out.leafbrushes.len())?; + out.validate(asset_core::AssetNamespace::Iw5)?; Ok(out) } @@ -1307,6 +1581,12 @@ fn extract_iw5_mesh_tables( out: &mut ClipCollision, ) -> Result<(), ClipCollisionError> { let mesh = std::sync::Arc::make_mut(&mut out.mesh); + if (g.vert_count != 0 && g.verts.is_none()) + || (g.tri_count != 0 && g.tri_indices.is_none()) + || (g.material_count != 0 && g.materials.is_none()) + { + return Err(ClipCollisionError::MissingTables); + } let Some(verts_ptr) = g.verts else { return Ok(()); }; @@ -1461,12 +1741,15 @@ fn extract_iw5_mesh_materials( .map_err(|_| ClipCollisionError::Truncated)?; mats.push(ClipMapMaterial { name, + source_surface_flags: surface_flags, surface_flags, content_flags, }); } } else { - mats.resize(g.material_count, ClipMapMaterial::default()); + if g.material_count != 0 { + return Err(ClipCollisionError::MissingTables); + } } mats }; @@ -1814,6 +2097,7 @@ pub fn build_t5_clip_collision( extract_t5_mesh_tables(s, g, &mut out)?; extract_t5_bsp_tables(s, g, &mut out)?; + out.validate(asset_core::AssetNamespace::T5)?; Ok(out) } @@ -1834,6 +2118,12 @@ fn extract_t5_mesh_tables( out: &mut ClipCollision, ) -> Result<(), ClipCollisionError> { let mesh = std::sync::Arc::make_mut(&mut out.mesh); + if (g.vert_count != 0 && g.verts.is_none()) + || (g.tri_count != 0 && g.tri_indices.is_none()) + || (g.material_count != 0 && g.materials.is_none()) + { + return Err(ClipCollisionError::MissingTables); + } let Some(verts_ptr) = g.verts else { return Ok(()); }; @@ -1876,6 +2166,7 @@ fn extract_t5_mesh_tables( let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); out.materials.push(ClipMapMaterial { name: String::from_utf8_lossy(&bytes[..end]).into_owned(), + source_surface_flags: s.u32_at(p, 64).map_err(|_| ClipCollisionError::Truncated)?, surface_flags: treyarch_surface_flags( s.u32_at(p, 64).map_err(|_| ClipCollisionError::Truncated)?, ), diff --git a/crates/asset_world/src/world_t6.rs b/crates/asset_world/src/world_t6.rs index d0df91f..aaca5e0 100644 --- a/crates/asset_world/src/world_t6.rs +++ b/crates/asset_world/src/world_t6.rs @@ -493,6 +493,7 @@ pub fn build_clip_collision(load: &ZoneLoad) -> Result Result Result, pub refusals: Vec, } diff --git a/crates/assets/src/lane/t5.rs b/crates/assets/src/lane/t5.rs index 06a9049..b5350c1 100644 --- a/crates/assets/src/lane/t5.rs +++ b/crates/assets/src/lane/t5.rs @@ -454,6 +454,7 @@ impl ZoneLane for T5Lane { fx_glass: None, map_content: Default::default(), impact_fx: sink.impact_fx.take_table(), + family_impact_fx: Vec::new(), reflection_probe_images, intermission_view, exp_fog, diff --git a/crates/assets/src/lane/t6.rs b/crates/assets/src/lane/t6.rs index 9571bf0..1f7decf 100644 --- a/crates/assets/src/lane/t6.rs +++ b/crates/assets/src/lane/t6.rs @@ -48,6 +48,7 @@ struct T6Content { pub fx: Vec, pub fx_materials: BTreeMap, pub camouflages: BTreeMap>, + pub impact_fx: Option, pub report: Vec, } @@ -1351,10 +1352,52 @@ fn capture_content( content.materials.len(), ipaks.len() )); + content.impact_fx = capture_impact_table(&zones); capture_effects(&zones, ipaks, &mut content); content } +const FX_IMPACT_ROWS: u32 = 21; +const FX_IMPACT_ENTRY: u32 = 144; +const FX_IMPACT_NONFLESH: u32 = 32; +const FX_IMPACT_FIRST_EXPLOSION_ROW: usize = 11; + +fn capture_impact_table( + zones: &[&fastfile_t6::ZoneLoad], +) -> Option { + zones.iter().find_map(|&load| { + let asset = load + .assets + .iter() + .find(|asset| asset.ty == fastfile_t6::AssetType::ImpactFx)?; + let table = decode_ptr(header_u32(&asset.header, 4)?)?; + let name_at = |cell: u32| { + load.asset_at(table.at(cell * 4)) + .and_then(|fx| header_str(load, &fx.header, 0)) + .unwrap_or_default() + .to_owned() + }; + let entries = (0..FX_IMPACT_ROWS) + .map(|row| { + let first = row * FX_IMPACT_ENTRY / 4; + // The 32nd nonflesh surface (riotshield) has no IW4-shaped column. + asset_game::OwnedFxImpactEntry { + nonflesh: std::array::from_fn(|surf| name_at(first + surf as u32)), + flesh: std::array::from_fn(|slot| { + name_at(first + FX_IMPACT_NONFLESH + slot as u32) + }), + } + }) + .collect(); + Some(asset_game::OwnedFxImpactTable { + namespace: asset_core::AssetNamespace::T6, + name: header_str(load, &asset.header, 0)?.to_owned(), + entries, + capture_gaps: 0, + }) + }) +} + const FX_ELEM_DEF: u32 = 292; const FX_ELEM_TYPE: usize = 184; const FX_ELEM_VEL_SAMPLES: usize = 188; @@ -1381,6 +1424,34 @@ fn capture_effects( .map(|&name| name.to_owned()) .collect(); wanted.insert("maps/mp_maps/fx_mp_exp_bomb".to_owned()); + if let Some(table) = &content.impact_fx { + wanted.extend( + table.entries[FX_IMPACT_FIRST_EXPLOSION_ROW..] + .iter() + .flat_map(|entry| entry.nonflesh.iter().chain(&entry.flesh)) + .filter(|name| !name.is_empty()) + .cloned(), + ); + } + for &load in zones { + for asset in load + .assets + .iter() + .filter(|asset| asset.ty == fastfile_t6::AssetType::Weapon) + { + let Some(weapon) = fastfile_t6::weapon::WeaponView::new(load, asset) else { + continue; + }; + use fastfile_t6::weapon::def; + wanted.extend( + [def::VIEW_FLASH_EFFECT, def::WORLD_FLASH_EFFECT] + .into_iter() + .filter_map(|off| weapon.def_asset_name(off)) + .filter(|name| !name.is_empty()) + .map(str::to_owned), + ); + } + } for load in zones { match destructible::effect_names(load) { Ok(names) => wanted.extend(names), @@ -2524,6 +2595,7 @@ impl ZoneLane for T6Lane { path: path.to_owned(), ..Default::default() }; + effect_content.impact_fx = capture_impact_table(&image_loads); capture_effects(&image_loads, &ipaks, &mut effect_content); let mut fx = asset_game::FxCatalog::default(); let mut effect_refusals = Vec::new(); diff --git a/crates/assets/src/lane/t6/common_compile.rs b/crates/assets/src/lane/t6/common_compile.rs index 130e08f..74001cc 100644 --- a/crates/assets/src/lane/t6/common_compile.rs +++ b/crates/assets/src/lane/t6/common_compile.rs @@ -27,6 +27,7 @@ impl CommonFamilyCompiler for T6CommonCompiler { } = products; let t6_captured = self.captured_weapons; let mut content = self.content; + let impact_fx = content.impact_fx.take(); let t6_hands = content.hands.clone(); let (added, kept) = xanims.absorb_vacant(std::mem::take(&mut content.xanims)); report.push(format!( @@ -92,6 +93,7 @@ impl CommonFamilyCompiler for T6CommonCompiler { fx: common_fx, report, }, + impact_fx, refusals, } } diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 9cf23d1..9956aa1 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -32,8 +32,8 @@ pub use lighting_iw4::MODEL_LIGHTING_TILE_BYTES; pub use loading_screen::{LoadingPreviewSource, LoadingScreen}; pub use map_load_process::MapLoadProcess; pub use match_load::{ - MapLoadApproval, MatchLoadAbort, MatchLoadAccepted, MatchLoadBusy, MatchLoadDispatch, - MatchLoadRequest, PreparedMatchReady, PreparedMatchSound, + CurrentMatchLoad, MapLoadApproval, MatchLoadAbort, MatchLoadAccepted, MatchLoadBusy, + MatchLoadDispatch, MatchLoadRequest, PreparedMatchReady, PreparedMatchSound, }; pub use plugin::AssetPlugin; pub use prepared::{ diff --git a/crates/assets/src/match_load.rs b/crates/assets/src/match_load.rs index e1e1397..5eb196b 100644 --- a/crates/assets/src/match_load.rs +++ b/crates/assets/src/match_load.rs @@ -20,6 +20,16 @@ pub struct MatchLoadBusy(pub bool); #[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)] pub struct MatchLoadAbort(pub u64); +#[derive(Resource, Default)] +pub struct CurrentMatchLoad(pub Option); + +fn current_load(world: &World, key: frame::LocalLoadKey) -> bool { + world.resource::().0 == Some(key) + && world + .get_resource::() + .is_none_or(|abort| abort.0 != key.local_load_request_id) +} + #[derive(Resource)] pub struct MatchLoadRequest { pub request_id: u64, @@ -87,7 +97,15 @@ fn start_match_load( .is_some_and(|abort| abort.0 == request.request_id) { *waiting_for_retirement = None; - commands.remove_resource::(); + let load_key = request.load_key; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|request| request.load_key == load_key) + { + world.remove_resource::(); + } + }); if busy.is_none() { inflight.0 = false; } @@ -132,7 +150,15 @@ fn start_match_load( zone: request.zone.clone(), error: error.clone(), }); - commands.remove_resource::(); + let load_key = request.load_key; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|request| request.load_key == load_key) + { + world.remove_resource::(); + } + }); inflight.0 = false; return; } @@ -148,12 +174,12 @@ fn start_match_load( // The process outlives the overlay and every task this spawns: it is what // the table reads, what admission finishes, and what the bench still has // once the screen is gone. - commands.insert_resource(MapLoadProcess::new( - request_id, - load_key, - zone.clone(), - cancel_handle.clone(), - )); + let process = MapLoadProcess::new(request_id, load_key, zone.clone(), cancel_handle.clone()); + commands.queue(move |world: &mut World| { + if current_load(world, load_key) { + world.insert_resource(process); + } + }); let families = request.families; let games = identity.map(|identity| asset_transport::GamesRoot(identity.games_root.clone())); @@ -210,6 +236,9 @@ fn start_match_load( }); inflight.0 = true; commands.queue(move |world: &mut World| { + if !current_load(world, load_key) { + return; + } frame::scope::insert( world, MatchLoadAccepted { @@ -222,6 +251,10 @@ fn start_match_load( ); }); commands.queue(move |world: &mut World| { + if !current_load(world, load_key) { + cancel_handle.cancel(); + return; + } frame::scope::insert( world, MatchLoadTask { @@ -234,7 +267,14 @@ fn start_match_load( frame::MatchScope::Loading, ); }); - commands.remove_resource::(); + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|request| request.load_key == load_key) + { + world.remove_resource::(); + } + }); } fn approve_map_load( @@ -246,8 +286,10 @@ fn approve_map_load( dvars: Res, server_families: Option>, abort: Option>, + mut current: ResMut, ) { for request in approved.read() { + current.0 = Some(request.load_key); if abort .as_ref() .is_some_and(|abort| abort.0 == request.request_id) @@ -319,7 +361,7 @@ fn approve_map_load( // Its own state, never the live map's: tasks from the request before // this one still hold handles, and they must not land in these rows. let progress = LoadProgress::new(request.request_id); - commands.insert_resource(MatchLoadRequest { + let candidate = MatchLoadRequest { request_id: request.request_id, load_key: request.load_key, zone, @@ -327,6 +369,11 @@ fn approve_map_load( common_mp, progress, families, + }; + commands.queue(move |world: &mut World| { + if current_load(world, candidate.load_key) { + world.insert_resource(candidate); + } }); } } @@ -337,14 +384,16 @@ fn poll_match_load( mut inflight: ResMut, abort: Option>, mut failed: MessageWriter, + current: Res, ) { let Some(task) = task.as_deref_mut() else { return; }; - if abort - .as_ref() - .is_some_and(|abort| abort.0 == task.request_id) + if (current.0 != Some(task.load_key) + || abort + .as_ref() + .is_some_and(|abort| abort.0 == task.request_id)) && !task.progress.is_canceled() { task.progress.cancel(); @@ -363,9 +412,25 @@ fn poll_match_load( Err(panic) => Err(walk_panic_failure(task, panic.as_ref())), }; let request_id = task.request_id; + let load_key = task.load_key; inflight.0 = false; - commands.remove_resource::(); - commands.remove_resource::(); + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|task| task.load_key == load_key) + { + world.remove_resource::(); + } + if world + .get_resource::() + .is_some_and(|accepted| accepted.load_key == load_key) + { + world.remove_resource::(); + } + }); + if current.0 != Some(load_key) { + return; + } let outcome = match outcome { Ok(ready) => ready, Err(error) => { @@ -414,9 +479,10 @@ fn poll_match_load( .unwrap_or_else(|| Err("the map zone never opened".to_owned())), }; commands.queue(move |world: &mut World| { + if !current_load(world, ready.load_key) { + return; + } frame::scope::insert(world, map_sound, frame::MatchScope::Loading); - }); - commands.queue(move |world: &mut World| { frame::scope::insert(world, ready, frame::MatchScope::Loading); }); } @@ -454,7 +520,18 @@ fn expire_match_load_abort( if task.is_some() || request.is_some() || ready.is_some() { return; } - commands.remove_resource::(); + let request_id = abort.expect("checked abort").0; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|abort| abort.0 == request_id) + && !world.contains_resource::() + && !world.contains_resource::() + && !world.contains_resource::() + { + world.remove_resource::(); + } + }); } pub fn register_match_load_systems(app: &mut App) { @@ -465,21 +542,23 @@ pub fn register_match_load_systems(app: &mut App) { OnExit(frame::MatchScope::Loading), cancel_loading.in_set(frame::ScopeSet::Release), ); - app.init_resource::().add_systems( - Update, - ( - approve_map_load.in_set(MapLoadApproval), + app.init_resource::() + .init_resource::() + .add_systems( + Update, ( - start_match_load.run_if(in_state(frame::MatchScope::Loading)), - poll_match_load.after(start_match_load), - expire_match_load_abort.after(poll_match_load), + approve_map_load.in_set(MapLoadApproval), + ( + start_match_load.run_if(in_state(frame::MatchScope::Loading)), + poll_match_load.after(start_match_load), + expire_match_load_abort.after(poll_match_load), + ) + .in_set(MatchLoadDispatch) + .after(MapLoadApproval), ) - .in_set(MatchLoadDispatch) - .after(MapLoadApproval), - ) - .after(frame::SessionSwapApplied) - .in_set(ClientSet::Load), - ); + .after(frame::SessionSwapApplied) + .in_set(ClientSet::Load), + ); } fn cancel_loading(task: Option>, mut busy: ResMut) { diff --git a/crates/assets/src/prepared.rs b/crates/assets/src/prepared.rs index 47e265c..03b9fe6 100644 --- a/crates/assets/src/prepared.rs +++ b/crates/assets/src/prepared.rs @@ -195,3 +195,133 @@ pub struct SessionCompass { #[derive(Clone, Copy, Debug, Default, Resource, PartialEq, Eq)] pub struct PreparedBodyClips(pub bool); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DependencyRole { + Authority, + Presentation, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DependencyImportance { + Required, + Optional, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreparedDependency { + pub key: String, + pub source: String, + pub role: DependencyRole, + pub importance: DependencyImportance, + pub available: bool, +} + +#[derive(Clone, Debug, Resource)] +pub struct PreparedCapabilities { + pub version: u32, + pub products: frame::WorldProducts, + pub dependencies: Vec, + pub script_fx: std::collections::BTreeSet, +} + +impl PreparedCapabilities { + pub fn prepare(prepared: &crate::PreparedMatch) -> Self { + let mut script_fx = std::collections::BTreeSet::new(); + let mut dependencies = Vec::new(); + for namespace in [ + asset_core::AssetNamespace::Iw4, + asset_core::AssetNamespace::Iw5, + asset_core::AssetNamespace::T5, + asset_core::AssetNamespace::T6, + ] { + for name in prepared.fx.names() { + if prepared.fx.get_in(namespace, name).is_none() { + continue; + } + let key = format!("{}:{name}", namespace.as_str()).to_ascii_lowercase(); + script_fx.insert(key.clone()); + script_fx.insert(name.to_ascii_lowercase()); + dependencies.push(PreparedDependency { + key, + source: format!("{} effect corpus", namespace.as_str()), + role: DependencyRole::Presentation, + importance: DependencyImportance::Optional, + available: true, + }); + } + } + for (key, source) in prepared.scripts.fx_references() { + let available = script_fx.contains(&key); + script_fx.insert(key.clone()); + dependencies.push(PreparedDependency { + key, + source, + role: DependencyRole::Presentation, + importance: DependencyImportance::Optional, + available, + }); + } + let has_crossbow = (1..=prepared.weapons.len() as u32) + .filter_map(|id| prepared.weapons.key_of(id)) + .any(|key| { + key.namespace == asset_core::AssetNamespace::T6 && key.name.contains("crossbow") + }); + if has_crossbow { + let effect = "weapon/crossbow/fx_trail_crossbow_blink_grn_os"; + for (key, available) in [ + ( + format!("t5:{effect}"), + prepared + .fx + .get_in(asset_core::AssetNamespace::T5, effect) + .is_some(), + ), + ( + "t5:wpn_crossbow_alert".into(), + prepared + .script_sound_aliases + .as_ref() + .is_some_and(|aliases| { + aliases + .get("t5:wpn_crossbow_alert") + .is_some_and(|facts| facts.is_some()) + }), + ), + ] { + dependencies.push(PreparedDependency { + key, + source: "t6 crossbow warning".into(), + role: DependencyRole::Presentation, + importance: DependencyImportance::Required, + available, + }); + } + } + Self { + version: 1, + products: frame::WorldProducts::from_walk(prepared.materials.products_id), + dependencies, + script_fx, + } + } + + pub fn validate(&self, role: frame::RuntimeRole) -> Result<(), String> { + for dependency in &self.dependencies { + let required_role = match dependency.role { + DependencyRole::Authority => role.runs_authority(), + DependencyRole::Presentation => role.runs_client(), + }; + if required_role + && dependency.importance == DependencyImportance::Required + && !dependency.available + { + return Err(format!( + "Required capability {} missing ({})", + dependency.key, dependency.source + )); + } + } + Ok(()) + } +} diff --git a/crates/assets/src/script_sources.rs b/crates/assets/src/script_sources.rs index 0a6afc6..a42fbcb 100644 --- a/crates/assets/src/script_sources.rs +++ b/crates/assets/src/script_sources.rs @@ -65,6 +65,70 @@ impl ScriptSources { names } + pub(crate) fn fx_references(&self) -> BTreeMap { + let mut references = BTreeMap::new(); + for (module, source) in &self.sources { + let Ok(bytes) = &source.bytes else { + continue; + }; + let mut at = 0; + while at < bytes.len() { + if bytes[at..].starts_with(b"//") { + while at < bytes.len() && bytes[at] != b'\n' { + at += 1; + } + } else if bytes[at..].starts_with(b"/*") { + at += 2; + while at + 1 < bytes.len() && &bytes[at..at + 2] != b"*/" { + at += 1; + } + at = (at + 2).min(bytes.len()); + } else if bytes[at] == b'"' { + at += 1; + while at < bytes.len() && bytes[at] != b'"' { + at += if bytes[at] == b'\\' { 2 } else { 1 }; + } + at = (at + 1).min(bytes.len()); + } else if bytes[at].is_ascii_alphabetic() || bytes[at] == b'_' { + let start = at; + while at < bytes.len() + && (bytes[at].is_ascii_alphanumeric() || bytes[at] == b'_') + { + at += 1; + } + if !bytes[start..at].eq_ignore_ascii_case(b"loadfx") { + continue; + } + let mut value = at; + while bytes.get(value).is_some_and(u8::is_ascii_whitespace) { + value += 1; + } + if bytes.get(value) != Some(&b'(') { + continue; + } + value += 1; + while bytes.get(value).is_some_and(u8::is_ascii_whitespace) { + value += 1; + } + if bytes.get(value) != Some(&b'"') { + continue; + } + value += 1; + let start = value; + while value < bytes.len() && bytes[value] != b'"' { + value += 1; + } + if let Ok(name) = std::str::from_utf8(&bytes[start..value]) { + references.insert(normalize(name), module.clone()); + } + } else { + at += 1; + } + } + } + references + } + pub fn schemas(&self) -> &BTreeMap> { &self.schemas } diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index 69bff0d..7a66eae 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -7,6 +7,7 @@ pub struct CommonKey { pub(super) runtime: Option, pub(super) foreign: Option, source_revision: u64, + collision_adapter_version: u32, families: frame::AssetFamilies, } @@ -20,6 +21,7 @@ impl CommonKey { let source_revision = games_root_from_env() .map_or(0, |games| asset_transport::installation_revision(&games.0)); Self { + collision_adapter_version: asset_world::COLLISION_ADAPTER_VERSION, source_revision, runtime: runtime.map(Path::to_path_buf), foreign: families @@ -39,6 +41,7 @@ impl CommonKey { } }; Self { + collision_adapter_version: asset_world::COLLISION_ADAPTER_VERSION, source_revision: asset_transport::installation_revision(&games.0), runtime, foreign: None, @@ -107,6 +110,7 @@ pub(super) struct CommonProducts { pub(super) t6_player_anim_sources: asset_anim::PlayerAnimSources, pub(super) t5_fx: FxCatalog, pub(super) t5_impact_fx: Option, + pub(super) t6_impact_fx: Option, pub(super) iw5_materials: MaterialCatalog, pub(super) iw5_scene_models: asset_world::MapXModelSceneCatalog, pub(super) iw5_shared_surfaces: asset_model::SharedXModelSurfaces, @@ -730,6 +734,7 @@ async fn prepare_common(key: CommonKey) -> Arc { t6_weapon_walk.await; common_report.extend(t6_report); weapons.absorb(t6_weapons); + let mut t6_impact_fx = None; if let Some(compiler) = preparation { let prepared = compiler.compile(crate::lane::CommonPreparationProducts { weapons, @@ -747,6 +752,7 @@ async fn prepare_common(key: CommonKey) -> Arc { .iter() .map(|cause| format!("common family preparation refused: {cause:?}")), ); + t6_impact_fx = prepared.impact_fx; let products = prepared.products; common_report.extend(products.report); ( @@ -943,6 +949,7 @@ async fn prepare_common(key: CommonKey) -> Arc { t6_player_anim_sources, t5_fx, t5_impact_fx, + t6_impact_fx, iw5_materials, iw5_scene_models, iw5_shared_surfaces, diff --git a/crates/assets/src/session_load/match_walk.rs b/crates/assets/src/session_load/match_walk.rs index 58633c3..50b499a 100644 --- a/crates/assets/src/session_load/match_walk.rs +++ b/crates/assets/src/session_load/match_walk.rs @@ -98,6 +98,7 @@ pub(super) async fn walk_prepared_match( t6_player_anim_sources, t5_fx, t5_impact_fx, + t6_impact_fx, iw5_materials, mut iw5_scene_models, iw5_shared_surfaces, @@ -720,12 +721,14 @@ pub(super) async fn walk_prepared_match( .into(), ); decode_fx_colour_maps(&world.fx, &mut global, &image_trees, &progress, &mut report); + world.family_impact_fx = [t5_impact_fx, t6_impact_fx].into_iter().flatten().collect(); if world.impact_fx.is_none() { - world.impact_fx = if map_namespace == Some(asset_core::AssetNamespace::T5) { - t5_impact_fx.or(common_impact) - } else { - common_impact - }; + world.impact_fx = world + .family_impact_fx + .iter() + .find(|table| Some(table.namespace) == map_namespace) + .cloned() + .or(common_impact); } else if let Some(common_table) = common_impact { report.push(format!( "impactfx: map table kept; common_mp table `{}` discarded", @@ -743,6 +746,14 @@ pub(super) async fn walk_prepared_match( } else { report.push("impactfx handoff: missing — combat play_oriented will miss cells".into()); } + for table in &world.family_impact_fx { + report.push(format!( + "impactfx family table: {:?}:`{}` rows={}", + table.namespace, + table.name, + table.row_count() + )); + } report.push(format!( "tracer catalog handoff: {} named ({} bound, {} unresolved)", common_tracers.len(), diff --git a/crates/assets/src/session_load/mod.rs b/crates/assets/src/session_load/mod.rs index c06c023..39fe412 100644 --- a/crates/assets/src/session_load/mod.rs +++ b/crates/assets/src/session_load/mod.rs @@ -129,6 +129,7 @@ pub struct PreparedWorld { pub map_content: Arc, pub impact_fx: Option, + pub family_impact_fx: Vec, pub reflection_probe_images: Vec>, pub intermission_view: Option, @@ -215,6 +216,7 @@ impl PreparedWorld { fx_glass: Default::default(), map_content: Default::default(), impact_fx: Default::default(), + family_impact_fx: Default::default(), reflection_probe_images: Default::default(), intermission_view: Default::default(), exp_fog: Default::default(), diff --git a/crates/assets/src/session_load/resident_map.rs b/crates/assets/src/session_load/resident_map.rs index 4b209e7..2aa20f7 100644 --- a/crates/assets/src/session_load/resident_map.rs +++ b/crates/assets/src/session_load/resident_map.rs @@ -76,18 +76,6 @@ pub async fn load_prepared_match( return MatchLoadOutcome::Ready(prepared); } } - let dropped = RESIDENT_MAP - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .take(); - if let Some(dropped) = dropped { - diag::info!( - World, - "resident map: `{}` walk #{} dropped before the next walk", - dropped.zone.path.display(), - dropped.prepared.materials.products_id - ); - } let (outcome, common) = walk_prepared_match(zone_ff, common_mp, progress.clone(), families).await; let MatchLoadOutcome::Ready(mut prepared) = outcome else { diff --git a/crates/audio/src/ambient.rs b/crates/audio/src/ambient.rs index 8fd3947..f7578ab 100644 --- a/crates/audio/src/ambient.rs +++ b/crates/audio/src/ambient.rs @@ -12,7 +12,6 @@ use bevy::{ use crate::backend::AudioScope; use crate::playback::SoundBank; use crate::sources::{DesiredSource, SourceCueRequest, SourceKey, SourceRenderGroup}; -use frame::{MatchScope, ScopeEpoch}; pub(crate) const MAP_BED_SLOT: u32 = 2; const MAP_EMITTER_SLOT: u32 = 3; @@ -29,7 +28,10 @@ pub struct SoundIwd(pub Arc); pub struct MapAmbientBooted(pub bool); #[derive(Resource, Default)] -pub(crate) struct SoundBankLoadAttempted(pub bool); +pub struct LoadingAudioOwner(pub Option); + +#[derive(Resource, Default)] +pub(crate) struct SoundBankLoadAttempted(pub Option); const SOUND_BANK_COMPOSE_STALL: std::time::Duration = std::time::Duration::from_secs(20); @@ -155,25 +157,29 @@ pub(crate) struct SoundBankNamespace { pub(crate) fn start_sound_bank_compose( mut attempted: ResMut, mut status: ResMut, + mut owner: ResMut, accepted: Option>, + current: Res, abort: Option>, identity: Option>, silent: Option>, resident: Res, mut commands: Commands, ) { - if attempted.0 { - return; - } - if silent.is_some() { - *status = LoadingAudioStatus::Ready; - attempted.0 = true; - return; - } let Some(accepted) = accepted else { return; }; - if abort.is_some_and(|abort| abort.0 == accepted.request_id) { + if current.0 != Some(accepted.load_key) + || abort.is_some_and(|abort| abort.0 == accepted.request_id) + || attempted.0 == Some(accepted.load_key) + { + return; + } + owner.0 = Some(accepted.load_key); + *status = LoadingAudioStatus::Pending; + if silent.is_some() { + *status = LoadingAudioStatus::Ready; + attempted.0 = Some(accepted.load_key); return; } let Some(identity) = identity else { @@ -182,7 +188,7 @@ pub(crate) fn start_sound_bank_compose( if accepted.zone.is_empty() || identity.games_root.as_os_str().is_empty() { return; } - attempted.0 = true; + attempted.0 = Some(accepted.load_key); let games = GamesRoot(identity.games_root.clone()); let zone = accepted.zone.clone(); let opened_zone = zone.clone(); @@ -211,6 +217,9 @@ pub(crate) fn start_sound_bank_compose( }; let load_key = accepted.load_key; commands.queue(move |world: &mut World| { + if world.resource::().0 != Some(load_key) { + return; + } frame::scope::insert( world, SoundBankCompose { @@ -234,6 +243,7 @@ pub(crate) fn install_sound_bank( mut compose: Option>, identity: Option>, accepted: Option>, + current: Res, abort: Option>, mut map_sound: Option>, process: Option>, @@ -245,16 +255,25 @@ pub(crate) fn install_sound_bank( return; }; - let superseded = accepted - .as_ref() - .is_some_and(|accepted| accepted.load_key != compose.load_key); + let superseded = current.0 != Some(compose.load_key) + || accepted + .as_ref() + .is_some_and(|accepted| accepted.load_key != compose.load_key); if abort.is_some_and(|abort| abort.0 == compose.load_key.local_load_request_id) || superseded { diag::info!( Audio, "audio: sound bank for `{}` dropped — the session moved on", compose.zone ); - commands.remove_resource::(); + let load_key = compose.load_key; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|compose| compose.load_key == load_key) + { + world.remove_resource::(); + } + }); return; } // Install on the load key, not `identity.zone`: gating on a display spelling @@ -282,7 +301,15 @@ pub(crate) fn install_sound_bank( compose.common_profile_id = arrived.common_profile_id; let products_id = arrived.products_id; let map = std::mem::replace(&mut arrived.sound, Err(String::new())); - commands.remove_resource::(); + let load_key = compose.load_key; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|sound| sound.load_key == load_key) + { + world.remove_resource::(); + } + }); let kept = resident .0 .as_ref() @@ -341,7 +368,15 @@ pub(crate) fn install_sound_bank( } return; }; - commands.remove_resource::(); + let load_key = compose.load_key; + commands.queue(move |world: &mut World| { + if world + .get_resource::() + .is_some_and(|compose| compose.load_key == load_key) + { + world.remove_resource::(); + } + }); let IwdOpen::Open(iwd) = std::mem::replace(&mut compose.iwd, IwdOpen::Open(Arc::default())) else { unreachable!("the bank is polled only once the archives are open"); @@ -396,10 +431,19 @@ pub(crate) fn install_sound_bank( zone: compose.zone.clone(), namespace, }; + let load_key = compose.load_key; commands.queue(move |world: &mut World| { + if world.resource::().0 != Some(load_key) + || world + .get_resource::() + .is_some_and(|abort| abort.0 == load_key.local_load_request_id) + { + return; + } frame::scope::insert( world, PreparedMatchAudio { + load_key, iwd: sound_iwd, clips: new_clips, bank: SoundBank(bank), @@ -428,7 +472,7 @@ pub(crate) fn boot_map_ambient_once( identity: Option>, script_sound: Option>, namespace: Option>, - epoch: Res>, + epoch: Option>, ready: Res, generation: Res, runtime: Res, @@ -495,7 +539,7 @@ pub(crate) fn boot_map_ambient_once( alias, emitter: None, scope: AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), group, }) else { continue; @@ -503,7 +547,7 @@ pub(crate) fn boot_map_ambient_once( sources.desired.push(DesiredSource { key: SourceKey { scope: AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), object, slot, }, @@ -538,13 +582,20 @@ pub enum LoadingAudioStatus { } #[derive(Resource)] -struct PreparedMatchAudio { +pub struct PreparedMatchAudio { + pub load_key: frame::LocalLoadKey, iwd: SoundIwd, clips: crate::ClipStore, bank: SoundBank, namespace: SoundBankNamespace, } +pub fn prepared_match_audio_key(world: &World) -> Option { + world + .get_resource::() + .map(|prepared| prepared.load_key) +} + pub fn stage_prepared_match_audio(world: &mut World) { if let Some(prepared) = world.remove_resource::() { frame::scope::stage(world, prepared.iwd); diff --git a/crates/audio/src/backend.rs b/crates/audio/src/backend.rs index 66e56bd..9a40660 100644 --- a/crates/audio/src/backend.rs +++ b/crates/audio/src/backend.rs @@ -4,12 +4,12 @@ use frame::ClientSet; pub(crate) use crate::render_core::AudioScope; use crate::runtime::AudioRuntime; -use frame::{MatchScope, ScopeEpoch}; +use frame::MatchScope; pub(crate) fn register(app: &mut App) { app.add_systems( StateTransition, - sync_scope_epoch + sync_presentation_epoch .in_set(bevy::state::state::StateTransitionSystems::TransitionSchedules) .after(frame::scope::ScopeRetired), ); @@ -23,24 +23,23 @@ pub(crate) fn register(app: &mut App) { app.world() .resource::() .set_fire_verdicts(verdicts); - app.init_resource::>() - .add_systems( - Update, - publish_audio_context - .in_set(ClientSet::Predict) - .after(frame::OwnerEventsPublished), - ) - .add_systems( - PostUpdate, - ( - submit_presented_audio, - cancel_audio_on_exit.after(submit_presented_audio), - ), - ); + app.add_systems( + Update, + publish_audio_context + .in_set(ClientSet::Predict) + .after(frame::OwnerEventsPublished), + ) + .add_systems( + PostUpdate, + ( + submit_presented_audio, + cancel_audio_on_exit.after(submit_presented_audio), + ), + ); } pub(crate) fn publish_audio_context( - epoch: Res>, + epoch: Option>, runtime: Res, verdicts: Option>, clips: Option>, @@ -59,7 +58,7 @@ pub(crate) fn publish_audio_context( if let Some(verdicts) = verdicts { runtime.set_fire_verdicts(verdicts.clone()); } - runtime.set_match_epoch(epoch.0); + runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref())); runtime.set_event_context( generation .and_then(|generation| generation.0) @@ -109,15 +108,15 @@ pub(crate) fn publish_audio_context( .or_else(|| frontend.as_ref().map(|frontend| frontend.clips.service())), ); if let Some(mix) = mix.as_mut() { - mix.reset_epoch(epoch.0); + mix.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref())); } if let Some(channels) = channels.as_mut() { - channels.reset_epoch(epoch.0); + channels.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref())); } runtime.set_cue_mix( mix.zip(channels) .map(|(mix, channels)| crate::cue_execution::CueMix { - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), gain: mix.gain.clone(), channels: channels.bindings(), }), @@ -168,17 +167,18 @@ fn cancel_audio_on_exit(mut exit: MessageReader, runtime: Res, - epoch: Res>, + epoch: Option>, settings: Option>, listeners: Query<&Transform, With>, destructibles: Option>, projectiles: Option>, + flames: Option>, map: Option>, menu: Option>, shellshock: Option>, breath: Option>, ) { - runtime.set_match_epoch(epoch.0); + runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref())); let mut listener = listeners.iter(); let pose = listener.next(); assert!(listener.next().is_none(), "more than one ambient listener"); @@ -196,6 +196,9 @@ fn submit_presented_audio( if let Some(projectiles) = projectiles { desired.extend(projectiles.desired.iter().cloned()); } + if let Some(flames) = flames { + desired.extend(flames.desired.iter().cloned()); + } if let Some(map) = map { desired.extend(map.desired.iter().cloned()); } @@ -211,8 +214,12 @@ fn submit_presented_audio( runtime.set_sources(desired); } -fn sync_scope_epoch(epoch: Res>, runtime: Res) { - runtime.set_match_epoch(epoch.0); +pub(crate) fn presentation_epoch(cursor: Option<&net::EntityEventCursor>) -> u64 { + cursor.map_or(0, net::EntityEventCursor::timeline) +} + +fn sync_presentation_epoch(epoch: Option>, runtime: Res) { + runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref())); } fn release_audio(mut runtime: ResMut) { diff --git a/crates/audio/src/breath.rs b/crates/audio/src/breath.rs index 47e8679..977d338 100644 --- a/crates/audio/src/breath.rs +++ b/crates/audio/src/breath.rs @@ -33,7 +33,7 @@ pub(crate) fn update( mut state: Local, mut sources: ResMut, runtime: Res, - epoch: Res>, + epoch: Option>, mut play: MessageWriter, ) { let client = local.as_ref().map(|local| local.0); @@ -45,9 +45,13 @@ pub(crate) fn update( .for_client(client) .map(|meta| meta.life_sequence.0) }); - let context = client - .zip(life) - .map(|(client, life)| (epoch.0, client.0, life)); + let context = client.zip(life).map(|(client, life)| { + ( + crate::backend::presentation_epoch(epoch.as_deref()), + client.0, + life, + ) + }); if sources.context != context { if state.active { stop_aliases( @@ -145,7 +149,7 @@ pub(crate) fn update( alias: cues.heartbeat.into(), emitter: client.map(|client| client.0), scope: crate::backend::AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), group: None, }) { @@ -156,7 +160,7 @@ pub(crate) fn update( sources.source = Some(DesiredSource { key: SourceKey { scope: crate::backend::AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), object: u64::from(client.expect("alive client").0), slot: 6, }, diff --git a/crates/audio/src/destructible_loops.rs b/crates/audio/src/destructible_loops.rs index 457c26b..799bfe1 100644 --- a/crates/audio/src/destructible_loops.rs +++ b/crates/audio/src/destructible_loops.rs @@ -9,7 +9,6 @@ use crate::backend::AudioScope; use crate::playback::SoundBank; use crate::runtime::AudioRuntime; use crate::sources::{DesiredSource, SourceKey}; -use frame::{MatchScope, ScopeEpoch}; struct PresentedLoop { alias: String, @@ -30,12 +29,12 @@ pub(crate) fn update( presented: Res, bank: Option>, namespace: Option>, - epoch: Res>, + epoch: Option>, runtime: Res, mut sources: ResMut, ) { - if sources.epoch != epoch.0 { - sources.epoch = epoch.0; + if sources.epoch != crate::backend::presentation_epoch(epoch.as_deref()) { + sources.epoch = crate::backend::presentation_epoch(epoch.as_deref()); sources.live.clear(); sources.desired.clear(); } @@ -86,7 +85,7 @@ pub(crate) fn update( alias: alias.into(), emitter: row.snd_ent, scope: AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), group: None, }) else { continue; @@ -105,7 +104,7 @@ pub(crate) fn update( source: DesiredSource { key: SourceKey { scope: AudioScope::Match, - epoch: epoch.0, + epoch: crate::backend::presentation_epoch(epoch.as_deref()), object: u64::from(owner), slot: 1, }, diff --git a/crates/audio/src/flame_loops.rs b/crates/audio/src/flame_loops.rs new file mode 100644 index 0000000..391671d --- /dev/null +++ b/crates/audio/src/flame_loops.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; + +use asset_game::WeaponSoundSlot; +use bevy::prelude::*; + +use crate::backend::AudioScope; +use crate::sources::{DesiredSource, SourceCueRequest, SourceKey}; +use crate::{AudioRuntime, FlameFire, SoundBank, WeaponSound}; + +const STREAM_GRACE_SECS: f32 = 0.25; + +struct FlameStream { + weapon: u32, + player_view: bool, + origin_inches: [f32; 3], + last_fire: f32, + source: Option, +} + +#[derive(Resource, Default)] +pub(crate) struct FlameSources { + context: Option, + next_version: u64, + live: HashMap, + pub desired: Vec, +} + +fn slot_pair( + player_view: bool, + world: WeaponSoundSlot, + player: WeaponSoundSlot, +) -> [WeaponSoundSlot; 2] { + if player_view { + [player, world] + } else { + [world, player] + } +} + +pub(crate) fn update( + mut fires: MessageReader, + weapons: Option>, + presented: Res, + bank: Option>, + time: Res