From efe6db6981e87908d2c51102d2718fbee5b233fb Mon Sep 17 00:00:00 2001 From: vtroc7 Date: Mon, 12 Oct 2026 00:00:38 +0300 Subject: [PATCH] Restore native soldiers, barrels, chat and tracers; align weapon unlock admission --- crates/asset_anim/src/animtree.rs | 42 ++++- crates/asset_anim/src/playeranim_parse.rs | 26 +++ crates/asset_game/src/fx_catalog.rs | 34 +++- crates/asset_game/src/tracer_catalog.rs | 4 + crates/asset_game/src/weapon_catalog.rs | 12 +- .../src/weapon_catalog/catalog_linking.rs | 47 ++++++ .../asset_game/src/weapon_catalog/registry.rs | 19 +-- crates/asset_transport/src/iwd.rs | 5 + crates/asset_transport/src/lib.rs | 4 +- crates/asset_world/src/map_declarations.rs | 6 + crates/assets/src/barrels.gsc.txt | 74 +++++++++ crates/assets/src/lane/helpers.rs | 8 +- crates/assets/src/lane/iw5.rs | 1 + crates/assets/src/lane/sink.rs | 54 +++++- crates/assets/src/lane/t6.rs | 123 +++++++++++++- crates/assets/src/lane/t6/common_compile.rs | 2 +- .../src/lane/t6/common_compile/effects.rs | 2 +- crates/assets/src/lane/t6/destructible.rs | 157 ++++++++++++++++++ crates/assets/src/map_scripts.rs | 12 +- .../assets/src/session_load/common_cache.rs | 8 +- .../assets/src/session_load/common_walks.rs | 32 +++- crates/assets/src/session_load/match_walk.rs | 12 +- crates/audio/src/destructible_loops.rs | 4 + crates/console/src/user_settings.rs | 27 ++- crates/fx_iw4/src/beam.rs | 10 +- crates/sim/src/chat.rs | 11 +- crates/sim/src/script/host/entity_damage.rs | 16 +- crates/sim/src/script/mod.rs | 2 +- crates/sim/src/t5_destructible.rs | 45 ++++- crates/ui/src/menu.rs | 28 ++++ crates/xmodel_runtime/src/destructible.rs | 1 + docs/RUN.md | 6 +- 32 files changed, 751 insertions(+), 83 deletions(-) create mode 100644 crates/assets/src/barrels.gsc.txt create mode 100644 crates/assets/src/lane/t6/destructible.rs diff --git a/crates/asset_anim/src/animtree.rs b/crates/asset_anim/src/animtree.rs index 0e41025..2fdea4e 100644 --- a/crates/asset_anim/src/animtree.rs +++ b/crates/asset_anim/src/animtree.rs @@ -70,6 +70,18 @@ impl PlayerAnimSources { return; } } + } else if family == asset_core::FamilyId::T6 && path == MULTIPLAYER_ANIMTREE_PATH { + let decoded = data.get(..4).and_then(|size| { + let size = u32::from_le_bytes(size.try_into().ok()?) as usize; + asset_transport::inflate_deflate(&data[4..], size) + .ok() + .filter(|bytes| bytes.len() == size) + }); + let Some(bytes) = decoded else { + self.decode_errors.push(path); + return; + }; + bytes } else { data.to_vec() }; @@ -112,16 +124,23 @@ impl PlayerAnimSources { self.parsed_script = Some(crate::playeranim_parse::parse_player_anim_script( script, tree, - self.family == Some(asset_core::AssetNamespace::T5), + matches!( + self.family, + Some(asset_core::FamilyId::T5 | asset_core::FamilyId::T6) + ), + (self.family == Some(asset_core::FamilyId::T6)) + .then_some(self.playeranim_types.as_deref()) + .flatten(), )); } - if self.family == Some(asset_core::AssetNamespace::T5) - && let (Some(Ok(tree)), Some(Ok(script)), Some(atr)) = ( - self.compiled.as_ref(), - self.parsed_script.as_ref(), - self.multiplayer_atr.as_deref(), - ) - { + if matches!( + self.family, + Some(asset_core::FamilyId::T5 | asset_core::FamilyId::T6) + ) && let (Some(Ok(tree)), Some(Ok(script)), Some(atr)) = ( + self.compiled.as_ref(), + self.parsed_script.as_ref(), + self.multiplayer_atr.as_deref(), + ) { self.compiled = Some(crate::atr_compile::compile_selected_multiplayer( atr, tree, script, )); @@ -129,7 +148,12 @@ impl PlayerAnimSources { && let Some(script) = self.playeranim_script.as_deref() { self.parsed_script = Some(crate::playeranim_parse::parse_player_anim_script( - script, tree, true, + script, + tree, + true, + (self.family == Some(asset_core::FamilyId::T6)) + .then_some(self.playeranim_types.as_deref()) + .flatten(), )); } } diff --git a/crates/asset_anim/src/playeranim_parse.rs b/crates/asset_anim/src/playeranim_parse.rs index ebd1256..f3bea04 100644 --- a/crates/asset_anim/src/playeranim_parse.rs +++ b/crates/asset_anim/src/playeranim_parse.rs @@ -134,6 +134,7 @@ pub(crate) fn parse_player_anim_script( script: &[u8], tree: &CompiledAnimTreeDefinition, t5_syntax: bool, + player_anim_types: Option<&[u8]>, ) -> Result, PlayerAnimParseError> { let mut parser = AtrParser::new(script); let mut parsed = ParsedPlayerAnimScript { @@ -153,6 +154,24 @@ pub(crate) fn parse_player_anim_script( let mut current_items: Option> = None; let mut current_event_items: Option> = None; let mut aliases: HashMap<(u8, String), u64> = HashMap::new(); + if let Some(types) = player_anim_types { + let mut types = AtrParser::new(types); + for index in 0..64 { + let name = types.parse(true); + if name.is_empty() { + break; + } + if index_ci(&name, anim_iw4::ANIM_PLAYERANIMTYPE_NAMES).is_none() { + continue; + } + for condition in [ + anim_iw4::ANIM_COND_PLAYERANIMTYPE, + anim_iw4::ANIM_COND_PLAYERANIMTYPEPRIMARY, + ] { + aliases.insert((condition, ascii_lower(&name)), 1u64 << index); + } + } + } loop { let token = parser.parse(true); @@ -457,6 +476,13 @@ fn resolve_cond_value( if ANIM_COND_IS_BITFLAGS[usize::from(index)] && token.eq_ignore_ascii_case("all") { return Some(ResolvedCondValue::Bits(u64::MAX)); } + if matches!( + index, + anim_iw4::ANIM_COND_PLAYERANIMTYPE | anim_iw4::ANIM_COND_PLAYERANIMTYPEPRIMARY + ) && let Some(bits) = aliases.get(&(index, ascii_lower(token))) + { + return Some(ResolvedCondValue::Bits(*bits)); + } let names = anim_cond_value_names(index); if let Some(v) = index_ci(token, names) { return Some(ResolvedCondValue::Bit(v as u8)); diff --git a/crates/asset_game/src/fx_catalog.rs b/crates/asset_game/src/fx_catalog.rs index 8f60087..da7e15b 100644 --- a/crates/asset_game/src/fx_catalog.rs +++ b/crates/asset_game/src/fx_catalog.rs @@ -578,6 +578,7 @@ impl FxCatalog { s: &fastfile_t5::ZoneStream<'_>, geometry: fastfile_t5::FxEffectDefGeometry, materials: &MaterialCatalog, + material_name: &impl Fn(fastfile_t5::Ptr) -> Option, ) { use fastfile_t5::size as sz_t5; let Some(name_ptr) = geometry.name else { @@ -616,7 +617,7 @@ impl FxCatalog { if let Some(arr) = geometry.elem_defs { for i in 0..geometry.elem_def_count { let elem_ptr = arr.at(i * sz_t5::FX_ELEM_DEF); - match leftover_capture_elem_t5(s, elem_ptr, materials) { + match leftover_capture_elem_t5(s, elem_ptr, materials, material_name) { Some(elem) => elems.push(elem), None => { self.capture_gaps += 1; @@ -1992,6 +1993,7 @@ fn leftover_capture_elem_t5( s: &fastfile_t5::ZoneStream<'_>, p: fastfile_t5::Ptr, materials: &MaterialCatalog, + material_name: &impl Fn(fastfile_t5::Ptr) -> Option, ) -> Option { use fastfile_t5::size as sz_t5; let raw = s.slice_at(p, 0, sz_t5::FX_ELEM_DEF).ok()?.to_vec(); @@ -2017,7 +2019,7 @@ fn leftover_capture_elem_t5( vis_count, sz_t5::FX_ELEM_VIS_STATE_SAMPLE, ); - let visuals = leftover_capture_visuals_t5(s, p, &view, materials); + let visuals = leftover_capture_visuals_t5(s, p, &view, materials, material_name); let (effect_on_impact, effect_on_impact_hint) = capture_named_child(leftover_read_name_t5( s, p, @@ -2228,6 +2230,7 @@ fn leftover_capture_visuals_t5( p: fastfile_t5::Ptr, view: &FxElemDefView, materials: &MaterialCatalog, + material_name: &impl Fn(fastfile_t5::Ptr) -> Option, ) -> Vec { use fastfile_t5::size as sz_t5; let t = view.elem_type; @@ -2249,8 +2252,8 @@ fn leftover_capture_visuals_t5( .map(|i| { let m = arr.at(i * sz_t5::FX_ELEM_MARK_VISUALS); mark_pair( - leftover_resolve_material_t5(s, materials, m.at(0)), - leftover_resolve_material_t5(s, materials, m.at(4)), + leftover_resolve_material_t5(s, materials, material_name, m.at(0)), + leftover_resolve_material_t5(s, materials, material_name, m.at(4)), ) }) .collect(); @@ -2278,11 +2281,21 @@ fn leftover_capture_visuals_t5( let arr = s.resolve_alias(arr); return (0..visual_count) .map(|i| { - leftover_resolve_material_t5(s, materials, arr.at(i * sz_t5::FX_ELEM_VISUALS)) + leftover_resolve_material_t5( + s, + materials, + material_name, + arr.at(i * sz_t5::FX_ELEM_VISUALS), + ) }) .collect(); } - return vec![leftover_resolve_material_t5(s, materials, vis)]; + return vec![leftover_resolve_material_t5( + s, + materials, + material_name, + vis, + )]; } if t == elem_type::MODEL { if visual_count > 1 { @@ -2347,6 +2360,7 @@ fn leftover_capture_visuals_t5( fn leftover_resolve_material_t5( s: &fastfile_t5::ZoneStream<'_>, materials: &MaterialCatalog, + material_name: &impl Fn(fastfile_t5::Ptr) -> Option, slot: fastfile_t5::Ptr, ) -> OwnedFxVisual { let slot_iw4 = t5_ptr_to_iw4(slot); @@ -2357,9 +2371,11 @@ fn leftover_resolve_material_t5( let index = materials .material_index(slot_iw4) .or_else(|| alias.and_then(|target| materials.material_index(target))); - let name = index - .and_then(|i| materials.materials.get(i.get()).map(|m| m.name.to_string())) - .filter(|n| !n.is_empty()); + let name = material_name(slot).or_else(|| { + index + .and_then(|i| materials.materials.get(i.get()).map(|m| m.name.to_string())) + .filter(|n| !n.is_empty()) + }); OwnedFxVisual::Material { material: FxElemMaterial::unresolved_for(Some(slot_iw4), alias), hint: name, diff --git a/crates/asset_game/src/tracer_catalog.rs b/crates/asset_game/src/tracer_catalog.rs index b493388..e16409d 100644 --- a/crates/asset_game/src/tracer_catalog.rs +++ b/crates/asset_game/src/tracer_catalog.rs @@ -255,6 +255,10 @@ impl TracerDefinitions { } } + pub fn index_named(&self, namespace: crate::AssetNamespace, name: &str) -> Option { + self.by_key.get(&(namespace, name.to_owned())).copied() + } + pub fn def_at(&self, index: usize) -> Option<&OwnedTracerDef> { self.defs.get(index) } diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 814f65a..e35c38e 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -3,7 +3,7 @@ mod editor; mod model_linking; pub use editor::{EditorWeaponCatalog, EditorWeaponSelection}; mod publication; -use catalog_linking::{material_hint_edge, stamp_combat_fx}; +use catalog_linking::{material_hint_edge, stamp_combat_fx, stamp_mw2_tracer}; mod capture_merge; use capture_merge::{ apply_leftover_default_anim_overrides, apply_leftover_default_sound_overrides, @@ -2093,6 +2093,13 @@ impl WeaponBuild { pub fn resolve_combat_fx(&mut self, fx: &crate::FxCatalog, tracers: &crate::TracerCatalog) { let n = self.registry.rows.len().min(self.combat_slots.len()); for i in 1..n { + let category = { + let row = &self.registry.rows[i]; + self.registry + .item_groups + .get(&(row.namespace, row.name.clone())) + .and_then(|group| crate::cac_category_from_item_group(group)) + }; let row = &mut self.registry.rows[i]; let Some(namespace) = row.component_namespace(WeaponComponent::Effect) else { row.combat_fx.refuse_preparation(); @@ -2105,6 +2112,9 @@ impl WeaponBuild { fx, tracers, ); + if row.namespace != crate::AssetNamespace::Iw4 { + stamp_mw2_tracer(&mut row.combat_fx, &row.facts, category, tracers); + } row.preparation.bind_combat_effects( &mut row.combat_fx, self.combat_slots[i], diff --git a/crates/asset_game/src/weapon_catalog/catalog_linking.rs b/crates/asset_game/src/weapon_catalog/catalog_linking.rs index d1cd4da..2bc3b95 100644 --- a/crates/asset_game/src/weapon_catalog/catalog_linking.rs +++ b/crates/asset_game/src/weapon_catalog/catalog_linking.rs @@ -497,3 +497,50 @@ pub(super) fn stamp_combat_fx( }; combat.last_shot_eject_pair_authored = slots.last_shot_pair_authored(); } + +fn mw2_tracer_name( + facts: &WeaponBodyFacts, + category: Option, +) -> Option<&'static str> { + use crate::CacAuthoredCategory as C; + if facts.weap_type != weapon_iw4::WEAPTYPE_BULLET + || facts.weap_class == weapon_iw4::WEAPCLASS_TURRET + { + return None; + } + Some(match category { + Some(C::Assault) => "assaultrifle", + Some(C::Smg | C::MachinePistol) => "smg", + Some(C::Lmg) => "lmg", + Some(C::Sniper) => "sniper", + Some(C::Shotgun) => "shotgun", + Some(C::Pistol) => "pistol", + _ => match facts.weap_class { + 0 => "assaultrifle", + 1 => "sniper", + 2 => "lmg", + 3 => "smg", + weapon_iw4::WEAPCLASS_SPREAD => "shotgun", + weapon_iw4::WEAPCLASS_PISTOL => "pistol", + _ => return None, + }, + }) +} + +pub(super) fn stamp_mw2_tracer( + combat: &mut WeaponCombatFx, + facts: &WeaponBodyFacts, + category: Option, + tracers: &crate::TracerCatalog, +) { + let Some(name) = mw2_tracer_name(facts, category) else { + combat.tracer_hint = None; + combat.tracer = AssetEdge::Absent; + return; + }; + combat.tracer_hint = Some(name.to_owned()); + combat.tracer = match tracers.index_named(crate::AssetNamespace::Iw4, name) { + Some(index) => AssetEdge::bind_order(index, tracers.zone_of(index)), + None => AssetEdge::Unresolved(AssetEdgeReason::CatalogMiss), + }; +} diff --git a/crates/asset_game/src/weapon_catalog/registry.rs b/crates/asset_game/src/weapon_catalog/registry.rs index ec6dfcc..5d67798 100644 --- a/crates/asset_game/src/weapon_catalog/registry.rs +++ b/crates/asset_game/src/weapon_catalog/registry.rs @@ -172,7 +172,12 @@ impl WeaponRegistry { && table.name.eq_ignore_ascii_case("mp/perkTable.csv") && table.lookup_row_in_col(1, name).is_some() }); - return if native_perk { + let native_weapon = infinity + && self + .families + .find(&crate::FamilyKey::new(namespace, name)) + .is_some(); + return if native_perk || native_weapon { Ok(Default::default()) } else { Err("unlock.missing_item") @@ -231,17 +236,7 @@ impl WeaponRegistry { .describe_configuration(id) .ok_or("unlock.missing_family")?; let family = selection.family.as_ref().ok_or("unlock.missing_family")?; - match self.item_unlock_requirement(family.namespace, &family.base) { - Err("unlock.missing_item") - if matches!( - family.namespace, - crate::AssetNamespace::Iw4 | crate::AssetNamespace::Iw5 - ) => - { - Ok(Default::default()) - } - requirement => requirement, - } + self.item_unlock_requirement(family.namespace, &family.base) } pub fn vehicle_compass(&self) -> impl Iterator { diff --git a/crates/asset_transport/src/iwd.rs b/crates/asset_transport/src/iwd.rs index cee6a21..6ff8708 100644 --- a/crates/asset_transport/src/iwd.rs +++ b/crates/asset_transport/src/iwd.rs @@ -522,6 +522,11 @@ pub fn inflate_zlib(data: &[u8]) -> Result, String> { .map_err(|error| format!("zlib inflate: {error:?}")) } +pub fn inflate_deflate(data: &[u8], limit: usize) -> Result, String> { + miniz_oxide::inflate::decompress_to_vec_with_limit(data, limit) + .map_err(|error| format!("deflate inflate: {error:?}")) +} + #[derive(Debug, Default, Clone)] pub struct IwdSoundIndex { dir: PathBuf, diff --git a/crates/asset_transport/src/lib.rs b/crates/asset_transport/src/lib.rs index bef3e8e..c5bd0b7 100644 --- a/crates/asset_transport/src/lib.rs +++ b/crates/asset_transport/src/lib.rs @@ -26,8 +26,8 @@ pub use discover::{ pub use ipak::{IPak, ipak_name_hash}; pub use iwd::{ IwdFile, IwdIndex, IwdSoundIndex, cached_iwd_dirs, game_main_for_zone, game_mains_by_root, - game_mains_under, inflate_zlib, installation_revision, iwd_entry_reads, iwd_read_cost, - read_iwd_named, read_text, + game_mains_under, inflate_deflate, inflate_zlib, installation_revision, iwd_entry_reads, + iwd_read_cost, read_iwd_named, read_text, }; pub use load_jobs::{CacheResult, Job, JobKind}; pub use namespace_trees::{NamespaceSoundIwd, NamespaceTree, NamespaceTrees}; diff --git a/crates/asset_world/src/map_declarations.rs b/crates/asset_world/src/map_declarations.rs index d1df93c..f67e508 100644 --- a/crates/asset_world/src/map_declarations.rs +++ b/crates/asset_world/src/map_declarations.rs @@ -123,6 +123,12 @@ impl MapDeclarations { ); } out.push_str("\n\tmaps\\mp\\_load::main();\n"); + if entity_blocks(entities) + .iter() + .any(|entity| entity.get("targetname").map(String::as_str) == Some("explodable_barrel")) + { + out.push_str("\tiw4l_maps\\barrels::main();\n"); + } if let Some(compass) = &self.compass { let _ = writeln!(out, "\tmaps\\mp\\_compass::setupMiniMap( \"{compass}\" );"); } diff --git a/crates/assets/src/barrels.gsc.txt b/crates/assets/src/barrels.gsc.txt new file mode 100644 index 0000000..e930f77 --- /dev/null +++ b/crates/assets/src/barrels.gsc.txt @@ -0,0 +1,74 @@ +main() +{ + barrels = getentarray("explodable_barrel", "targetname"); + if (!barrels.size) + return; + level.barrel_effects["ignite"] = loadfx("props/barrel_ignite"); + level.barrel_effects["fire"] = loadfx("props/barrel_fire_top"); + level.barrel_effects["explode"] = loadfx("props/barrelexp"); + precachemodel("com_barrel_piece"); + precachemodel("com_barrel_piece2"); + for (i = 0; i < barrels.size; i++) + barrels[i] thread watch_damage(); +} + +watch_damage() +{ + self endon("exploding"); + self.barrel_damage = 0; + self setcandamage(true); + for (;;) + { + self waittill("damage", amount, attacker, direction, point, means); + if (means == "MOD_MELEE" || means == "MOD_IMPACT") + continue; + self.barrel_attacker = attacker; + first_hit = self.barrel_damage == 0; + self.barrel_damage += amount; + if (first_hit) + self thread burn(means); + } +} + +burn(means) +{ + self endon("exploding"); + up = anglestoup(self.angles); + if (means != "MOD_GRENADE_SPLASH" && means != "MOD_GRENADE") + { + playfx(level.barrel_effects["ignite"], self.origin); + fire = playloopedfx(level.barrel_effects["fire"], .05, self.origin + up * 44); + while (self.barrel_damage < 150) + { + self.barrel_damage += 10 + randomfloat(10); + wait 1; + } + fire delete(); + } + self thread explode(); +} + +explode() +{ + self notify("exploding"); + self notify("death"); + self setcandamage(false); + self playsound("iw5:explo_metal_rand"); + playfx(level.barrel_effects["explode"], self.origin + (0, 0, 4)); + if (isdefined(self.target)) + { + clip = getent(self.target, "targetname"); + if (isdefined(clip)) + clip delete(); + } + radius = 250; + if (isdefined(self.radius)) + radius = self.radius; + self radiusdamage(self.origin + (0, 0, 30), radius, 250, 1, self.barrel_attacker, "MOD_EXPLOSIVE"); + physicsexplosionsphere(self.origin + (0, 0, 30), radius, radius / 2, 2); + earthquake(.4, .5, self.origin, 512); + if (randomint(2)) + self setmodel("com_barrel_piece"); + else + self setmodel("com_barrel_piece2"); +} diff --git a/crates/assets/src/lane/helpers.rs b/crates/assets/src/lane/helpers.rs index 9b95f62..e1e6c5b 100644 --- a/crates/assets/src/lane/helpers.rs +++ b/crates/assets/src/lane/helpers.rs @@ -214,10 +214,14 @@ impl MapXModelCatalog { break_health: stream.f32_at(st, 4).ok()?, max_time: stream.f32_at(st, 8).ok()?, flags: stream.u32_at(st, 12).ok()?, + break_effect_looping: break_effect + .as_deref() + .and_then(|name| fx.get_in(asset_core::FamilyId::T5, name)) + .is_some_and(|effect| effect.view.msec_looping_life == i32::MAX), break_effect, - break_sound: read_string(st, 20)?, + break_sound: read_string(st, 20)?.map(|name| format!("t5:{name}")), break_notify: read_string(st, 24)?, - loop_sound: read_string(st, 28)?, + loop_sound: read_string(st, 28)?.map(|name| format!("t5:{name}")), has_phys_preset: stream.ptr_at(st, 44).ok()? != ZonePtr::Null, spawn_models, }); diff --git a/crates/assets/src/lane/iw5.rs b/crates/assets/src/lane/iw5.rs index 2edc8a5..96079df 100644 --- a/crates/assets/src/lane/iw5.rs +++ b/crates/assets/src/lane/iw5.rs @@ -755,6 +755,7 @@ impl ZoneLane for Iw5Lane { fpv: sink.fpv_meshes, world_weapons: sink.world_weapons, xanims: sink.xanims, + player_anim_sources: sink.player_anim_sources, light_defs, fx: sink.fx, scene_models: sink.scene_models, diff --git a/crates/assets/src/lane/sink.rs b/crates/assets/src/lane/sink.rs index cf2199b..a275daf 100644 --- a/crates/assets/src/lane/sink.rs +++ b/crates/assets/src/lane/sink.rs @@ -165,6 +165,7 @@ pub(crate) struct ZoneWalkSink { pub models: ModelCensus, pub materials: MaterialCatalog, pub fx: FxCatalog, + material_names_t5: HashMap, pub fx_models: asset_game::FxModelCatalog, pub impact_fx: ImpactFxCatalog, pub tracers: asset_game::TracerCatalog, @@ -738,6 +739,12 @@ impl fastfile_iw5::AssetLinkSink for CommonWalkSink { if let Some(sound) = self.sound.as_mut() { sound.raw_file(name, data, zlib_compressed); } + self.player_anim_sources.capture( + asset_core::AssetNamespace::Iw5, + name, + data, + zlib_compressed, + ); capture_film_vision(&mut self.film_visions, name, data, zlib_compressed); Ok(()) } @@ -890,7 +897,17 @@ impl fastfile_t5::AssetLinkSink for CommonWalkSink { s: &fastfile_t5::ZoneStream<'_>, geometry: fastfile_t5::FxEffectDefGeometry, ) -> fastfile_t5::Result<()> { - self.fx.capture_t5(s, geometry, &self.materials); + self.fx.capture_t5(s, geometry, &self.materials, &|slot| { + let name = self.material_names_t5.get(&slot).copied().or_else(|| { + let fastfile_t5::ZonePtr::Offset(target) = s.ptr_at(slot, 0).ok()? else { + return None; + }; + self.material_names_t5 + .get(&s.resolve_alias(target)) + .copied() + })?; + s.cstr(s.resolve_alias(name)).ok().map(str::to_owned) + }); Ok(()) } @@ -1006,7 +1023,17 @@ impl fastfile_t5::AssetLinkSink for ZoneWalkSink { s: &fastfile_t5::ZoneStream<'_>, geometry: fastfile_t5::FxEffectDefGeometry, ) -> fastfile_t5::Result<()> { - self.fx.capture_t5(s, geometry, &self.materials); + self.fx.capture_t5(s, geometry, &self.materials, &|slot| { + let name = self.material_names_t5.get(&slot).copied().or_else(|| { + let fastfile_t5::ZonePtr::Offset(target) = s.ptr_at(slot, 0).ok()? else { + return None; + }; + self.material_names_t5 + .get(&s.resolve_alias(target)) + .copied() + })?; + s.cstr(s.resolve_alias(name)).ok().map(str::to_owned) + }); Ok(()) } @@ -1053,6 +1080,13 @@ impl fastfile_t5::AssetLinkSink for ZoneWalkSink { target: fastfile_t5::Ptr, ) -> fastfile_t5::Result<()> { self.materials.t5_alias(ty, slot, target); + if ty == fastfile_t5::AssetType::Material { + if let Some(name) = self.material_names_t5.get(&target).copied() { + self.material_names_t5.insert(slot, name); + } else { + self.material_names_t5.remove(&slot); + } + } if ty == fastfile_t5::AssetType::Fx { self.fx.note_alias(t5_iw4_ptr(slot), t5_iw4_ptr(target)); } @@ -1071,6 +1105,22 @@ impl fastfile_t5::AssetLinkSink for ZoneWalkSink { Ok(()) } + fn remember_material_name( + &mut self, + slot: fastfile_t5::Ptr, + insert_slot: Option, + name: fastfile_t5::Ptr, + ) { + self.material_names_t5.insert(slot, name); + if let Some(slot) = insert_slot { + self.material_names_t5.insert(slot, name); + } + } + + fn material_name_ptr(&self, slot: fastfile_t5::Ptr) -> Option { + self.material_names_t5.get(&slot).copied() + } + fn capture_raw_file( &mut self, name: &str, diff --git a/crates/assets/src/lane/t6.rs b/crates/assets/src/lane/t6.rs index 52e68e7..2394612 100644 --- a/crates/assets/src/lane/t6.rs +++ b/crates/assets/src/lane/t6.rs @@ -1,5 +1,6 @@ mod camouflage; mod common_compile; +mod destructible; use std::collections::BTreeMap; use std::path::Path; @@ -53,6 +54,7 @@ struct T6Content { fn t6_script_model_instance( placement: &asset_world::ScriptModelPlacement, scene_assets: &mut asset_world::MapXModelSceneCatalog, + definitions: &BTreeMap>, ) -> asset_world::ScriptModelSceneInstance { use bevy::math::{EulerRot, Quat, Vec3}; let current_model = asset_world::MapXModelAssetKey(placement.model.clone()); @@ -64,10 +66,20 @@ fn t6_script_model_instance( }, ); } + let definition = definitions.get(&placement.destructible_def).cloned(); + let mut dobj_state = + xmodel_runtime::DObjSemanticState::bind_pose(placement.model.clone(), 1, 1); + if let Some(def) = &definition + && placement.model == def.model + && let Some(asset_world::MapXModelSceneAsset::T6(skel)) = scene_assets.get(¤t_model) + { + let health: Vec<_> = def.pieces.iter().map(|piece| piece.health as i16).collect(); + dobj_state.hide_part_bits = def.hide_parts(&health, &skel.bone_names); + } let [pitch, yaw, roll] = placement.angles.map(f32::to_radians); asset_world::ScriptModelSceneInstance { id: placement.id, - dobj_state: xmodel_runtime::DObjSemanticState::bind_pose(placement.model.clone(), 1, 1), + dobj_state, current_model, transform: bevy::prelude::Transform { translation: Vec3::from_array(placement.origin), @@ -81,7 +93,7 @@ fn t6_script_model_instance( script_noteworthy: placement.script_noteworthy.clone(), destructible_type: placement.destructible_type.clone(), destructible_def: placement.destructible_def.clone(), - t5_destructible: None, + t5_destructible: definition, target: placement.target.clone(), script_exploder: placement.script_exploder.clone(), brush_link: placement.brush_link.clone(), @@ -207,6 +219,71 @@ fn capture_xanims( )); } +fn capture_player_animation( + load: &fastfile_t6::ZoneLoad, + others: &[fastfile_t6::ZoneLoad], + xanims: &mut asset_anim::XAnimBuild, + report: &mut Vec, +) -> asset_anim::PlayerAnimSources { + let mut sources = asset_anim::PlayerAnimSources::default(); + for load in std::iter::once(load).chain(others) { + for asset in &load.assets { + if asset.ty != fastfile_t6::AssetType::RawFile { + continue; + } + let Some(name) = header_str(load, &asset.header, 0).filter(|name| { + [ + asset_anim::MULTIPLAYER_ANIMTREE_PATH, + asset_anim::PLAYERANIM_SCRIPT_PATH, + asset_anim::PLAYERANIM_TYPES_PATH, + ] + .contains(name) + }) else { + continue; + }; + let data = header_u32(&asset.header, 4).and_then(|size| { + let ptr = decode_ptr(header_u32(&asset.header, 8)?)?; + load.blocks.bytes(ptr, size as usize).ok() + }); + let Some(data) = data else { + report.push(format!( + "T6 character animation source {name}: invalid raw file" + )); + continue; + }; + sources.capture(asset_core::FamilyId::T6, name, data, false); + } + } + sources.compile(); + report.push(sources.compile_report_line()); + report.push(sources.parse_report_line()); + let mut wanted: std::collections::BTreeSet<_> = sources + .compiled() + .and_then(|tree| tree.as_ref().ok()) + .into_iter() + .flat_map(|tree| tree.nodes()) + .filter(|node| node.child_count == 0) + .map(|node| node.name.clone()) + .collect(); + let total = wanted.len(); + let mut captured = 0; + for load in std::iter::once(load).chain(others) { + for asset in &load.assets { + if asset.ty == fastfile_t6::AssetType::XAnimParts + && header_str(load, &asset.header, 0) + .is_some_and(|name| wanted.remove(&name.to_ascii_lowercase())) + && xanims.capture_xanim_t6(asset_core::FamilyId::T6, "", load, asset) + { + captured += 1; + } + } + } + report.push(format!( + "T6 character animations: {captured} of {total} clips captured" + )); + sources +} + fn capture_sounds( path: &Path, load: &fastfile_t6::ZoneLoad, @@ -1303,6 +1380,12 @@ fn capture_effects( .map(|&name| name.to_owned()) .collect(); wanted.insert("maps/mp_maps/fx_mp_exp_bomb".to_owned()); + for load in zones { + match destructible::effect_names(load) { + Ok(names) => wanted.extend(names), + Err(error) => content.report.push(error), + } + } loop { let before = content.fx.len(); for &load in zones { @@ -1903,6 +1986,8 @@ impl ZoneLane for T6Lane { } let script_placements = asset_world::parse_script_model_placements(entity_string(&load)?); + let destructibles = destructible::capture(&load)?; + let destructible_models = destructible::model_names(&destructibles); let mut script_xmodels: Vec = Vec::new(); for asset in &load.assets { let Some(model) = asset_model::T6Model::new(&load, asset) else { @@ -1911,7 +1996,8 @@ impl ZoneLane for T6Lane { let Some(name) = model.name() else { continue; }; - if script_placements.iter().any(|p| p.model == name) + if (script_placements.iter().any(|p| p.model == name) + || destructible_models.contains(name)) && !script_xmodels.iter().any(|seen| seen.name() == Some(name)) { script_xmodels.push(model); @@ -2063,7 +2149,13 @@ impl ZoneLane for T6Lane { } let script_model_instances = script_placements .iter() - .map(|placement| t6_script_model_instance(placement, &mut map_xmodel_scene_assets)) + .map(|placement| { + t6_script_model_instance( + placement, + &mut map_xmodel_scene_assets, + &destructibles, + ) + }) .collect::>(); report.push(format!( "T6 script models: placements={} models={}", @@ -2388,6 +2480,25 @@ impl ZoneLane for T6Lane { None } }; + let mut effect_content = T6Content { + path: path.to_owned(), + ..Default::default() + }; + capture_effects(&image_loads, &ipaks, &mut effect_content); + let mut fx = asset_game::FxCatalog::default(); + let mut effect_refusals = Vec::new(); + report.push(common_compile::bind_t6_fx( + &mut effect_content, + &mut materials, + &mut fx, + &mut effect_refusals, + )); + report.extend(effect_content.report); + report.extend( + effect_refusals + .into_iter() + .map(|refusal| format!("T6 map effects: {refusal:?}")), + ); let world = crate::session_load::PreparedWorld { min: draw.stats.min, max: draw.stats.max, @@ -2399,6 +2510,7 @@ impl ZoneLane for T6Lane { static_model_instances, map_xmodel_scene_assets, script_model_instances, + fx, smodel_lighting_samples, policy: WorldDrawPolicy::t6(), intermission_view: asset_world::parse_intermission_view(&entities), @@ -2506,6 +2618,8 @@ impl ZoneLane for T6Lane { .collect(); let mut content = capture_content(path, &load, &others, &ipaks); capture_xanims(&load, &others, &mut content); + let player_anim_sources = + capture_player_animation(&load, &others, &mut content.xanims, &mut report); let icon_loads: Vec<_> = others.iter().chain(std::iter::once(&load)).collect(); let icons = capture_weapon_icons(path, &icon_loads, &ipaks, &mut report); @@ -2519,6 +2633,7 @@ impl ZoneLane for T6Lane { )); CommonCensus { ui_images: icons, + player_anim_sources, weapons, material_population: material_seed, report, diff --git a/crates/assets/src/lane/t6/common_compile.rs b/crates/assets/src/lane/t6/common_compile.rs index ce7cae0..130e08f 100644 --- a/crates/assets/src/lane/t6/common_compile.rs +++ b/crates/assets/src/lane/t6/common_compile.rs @@ -1,6 +1,6 @@ mod effects; mod models; -use effects::bind_t6_fx; +pub(super) use effects::bind_t6_fx; use models::bind_t6_content; use crate::lane::{ diff --git a/crates/assets/src/lane/t6/common_compile/effects.rs b/crates/assets/src/lane/t6/common_compile/effects.rs index fc65e57..eff19ec 100644 --- a/crates/assets/src/lane/t6/common_compile/effects.rs +++ b/crates/assets/src/lane/t6/common_compile/effects.rs @@ -1,7 +1,7 @@ use crate::lane::CommonDependencyRefusal; use asset_material::MaterialCatalog; -pub(super) fn bind_t6_fx( +pub(in crate::lane::t6) fn bind_t6_fx( content: &mut super::super::T6Content, materials: &mut MaterialCatalog, catalog: &mut asset_game::FxCatalog, diff --git a/crates/assets/src/lane/t6/destructible.rs b/crates/assets/src/lane/t6/destructible.rs new file mode 100644 index 0000000..c36e3bd --- /dev/null +++ b/crates/assets/src/lane/t6/destructible.rs @@ -0,0 +1,157 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use asset_world::world_t6::Reader; +use fastfile_t6::{AssetType, LoadedAsset, Ptr, ZoneLoad}; +use xmodel_runtime::{T5DestructibleDef, T5DestructiblePiece, T5DestructibleStage}; + +pub(super) fn capture(load: &ZoneLoad) -> Result>, String> { + load.assets + .iter() + .filter(|asset| asset.ty == AssetType::DestructibleDef) + .map(|asset| { + let reader = Reader(load); + let name = + super::header_str(load, &asset.header, 0).ok_or("T6 destructible name missing")?; + let model = asset + .field(4) + .and_then(|index| load.assets.get(index)) + .and_then(|model| super::header_str(load, &model.header, 0)) + .ok_or_else(|| format!("T6 destructible {name}: model missing"))?; + let count = Reader::word(&asset.header, 12)? as i32; + if count <= 0 { + return Err(format!("T6 destructible {name}: invalid piece count")); + } + let address = Reader::ptr(&asset.header, 16)?; + reader.bytes( + address, + (count as usize) + .checked_mul(312) + .ok_or("T6 destructible pieces overflow")?, + )?; + let mut pieces = Vec::with_capacity(count as usize); + for index in 0..count as u32 { + let p = address.at(index * 312); + let mut stages = Vec::with_capacity(5); + for stage in 0..5 { + let st = p.at(stage * 48); + let bone = reader.u16(st)?; + let show_bone = if bone == 0 { + None + } else { + Some( + load.script_string(bone) + .ok_or("T6 destructible bone missing")? + .to_owned(), + ) + }; + let mut spawn_models = [None, None, None]; + for (slot, model) in spawn_models.iter_mut().enumerate() { + *model = asset_name(load, asset, st.at(32 + slot as u32 * 4))?; + } + stages.push(T5DestructibleStage { + show_bone, + break_health: reader.f32(st.at(4))?, + max_time: reader.f32(st.at(8))?, + flags: reader.u32(st.at(12))?, + break_effect: asset_name(load, asset, st.at(16))?, + break_effect_looping: load + .asset_in(asset, st.at(16)) + .and_then(|effect| super::header_u32(&effect.header, 20)) + .is_some_and(|duration| duration == i32::MAX as u32), + break_sound: text(load, st.at(20))?.map(|name| format!("t6:{name}")), + break_notify: text(load, st.at(24))?, + loop_sound: text(load, st.at(28))?.map(|name| format!("t6:{name}")), + spawn_models, + has_phys_preset: reader.u32(st.at(44))? != 0, + }); + } + let mut hide_bones = [0; 5]; + for (word, bits) in hide_bones.iter_mut().enumerate() { + *bits = reader.u32(p.at(292 + word as u32 * 4))?; + } + let launch = if let Some(c) = load + .blocks + .ptr_at(p.at(268)) + .map_err(|e| format!("T6 destructible constraints: {e:?}"))? + { + let data = c.at(8); + if reader.u32(c.at(4))? > 0 && reader.u32(data.at(4))? == 7 { + Some((reader.xyz(data.at(116))?, reader.f32(data.at(112))?)) + } else { + None + } + } else { + None + }; + pieces.push(T5DestructiblePiece { + stages: stages.try_into().unwrap(), + parent_piece: reader.bytes(p.at(240), 1)?[0], + parent_damage_percent: reader.f32(p.at(244))?, + bullet_damage_scale: reader.f32(p.at(248))?, + explosive_damage_scale: reader.f32(p.at(252))?, + health: reader.u32(p.at(272))? as i32, + launch, + hide_bones, + }); + } + Ok(( + name.to_owned(), + Arc::new(T5DestructibleDef { + name: name.to_owned(), + model: model.to_owned(), + pieces, + client_only: Reader::word(&asset.header, 20)? != 0, + }), + )) + }) + .collect() +} + +pub(super) fn model_names( + definitions: &BTreeMap>, +) -> BTreeSet<&str> { + definitions + .values() + .flat_map(|def| { + std::iter::once(def.model.as_str()).chain(def.pieces.iter().flat_map(|piece| { + piece + .stages + .iter() + .flat_map(|stage| stage.spawn_models.iter().filter_map(Option::as_deref)) + })) + }) + .collect() +} + +pub(super) fn effect_names(load: &ZoneLoad) -> Result, String> { + Ok(capture(load)? + .values() + .flat_map(|def| { + def.pieces.iter().flat_map(|piece| { + piece + .stages + .iter() + .filter_map(|stage| stage.break_effect.clone()) + }) + }) + .collect()) +} + +fn asset_name(load: &ZoneLoad, owner: &LoadedAsset, slot: Ptr) -> Result, String> { + if Reader(load).u32(slot)? == 0 { + return Ok(None); + } + load.asset_in(owner, slot) + .and_then(|asset| super::header_str(load, &asset.header, 0)) + .map(|name| Some(name.to_owned())) + .ok_or_else(|| "T6 destructible asset reference missing".into()) +} + +fn text(load: &ZoneLoad, slot: Ptr) -> Result, String> { + load.blocks + .ptr_at(slot) + .map_err(|e| format!("T6 destructible string: {e:?}"))? + .map(|p| Reader(load).text(p).map(str::to_owned)) + .transpose() +} diff --git a/crates/assets/src/map_scripts.rs b/crates/assets/src/map_scripts.rs index 4b1cccb..47a0028 100644 --- a/crates/assets/src/map_scripts.rs +++ b/crates/assets/src/map_scripts.rs @@ -1,3 +1,5 @@ +pub(crate) const BARRELS: &str = include_str!("barrels.gsc.txt"); + pub(crate) const T6_EQUIPMENT: &str = include_str!("t6_equipment.gsc.txt"); pub(crate) const RADIATION: &str = r#"main() @@ -94,6 +96,10 @@ broken(event, attacker) self thread ignite(attacker); else if (event == "destructible_car_explosion" || event == "destructible_barrel_explosion" || event == "explode") self explode(attacker); + else if (event == "explode_sm") + self explode(attacker, 150); + else if (event == "explode_lg") + self explode(attacker, 450); } ignite(attacker) @@ -103,14 +109,16 @@ ignite(attacker) self thread explode(attacker); } -explode(attacker) +explode(attacker, radius) { + if (!isdefined(radius)) + radius = 256; if (isdefined(self.wrecked) && self.wrecked) return; self.wrecked = true; self notify("wrecked"); self damagepiece(20000, -1, attacker); - self radiusdamage(self.origin, 256, 300, 75, attacker, "MOD_EXPLOSIVE"); + self radiusdamage(self.origin, radius, 300, 75, attacker, "MOD_EXPLOSIVE"); earthquake(.5, .5, self.origin, 800); level.globalCarsDestroyed++; } diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index 54ad656..fb6b5a0 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -95,7 +95,9 @@ pub(super) struct CommonProducts { pub(super) fx_models: asset_game::FxModelCatalog, pub(super) impact_fx: Option, pub(super) t5_xanims: XAnimBuild, + pub(super) iw5_player_anim_sources: asset_anim::PlayerAnimSources, pub(super) t5_player_anim_sources: asset_anim::PlayerAnimSources, + pub(super) t6_player_anim_sources: asset_anim::PlayerAnimSources, pub(super) t5_fx: FxCatalog, pub(super) t5_impact_fx: Option, pub(super) iw5_materials: MaterialCatalog, @@ -633,6 +635,7 @@ async fn prepare_common(key: CommonKey) -> Arc { fpv: iw5_fpv, world_guns: iw5_world_guns, xanims: iw5_xanims, + player_anim_sources: iw5_player_anim_sources, materials: iw5_materials, stats_tables: iw5_census_stats, scene_models: iw5_scene_models, @@ -683,7 +686,8 @@ async fn prepare_common(key: CommonKey) -> Arc { weapons.len(), weapons.namespace_count(asset_core::AssetNamespace::T5) )); - let (t6_weapons, preparation, t6_tables, t6_report, t6_ui_images) = t6_weapon_walk.await; + let (t6_weapons, preparation, t6_tables, t6_report, t6_ui_images, t6_player_anim_sources) = + t6_weapon_walk.await; common_report.extend(t6_report); weapons.absorb(t6_weapons); if let Some(compiler) = preparation { @@ -895,6 +899,8 @@ async fn prepare_common(key: CommonKey) -> Arc { impact_fx: common_impact, t5_xanims, t5_player_anim_sources, + iw5_player_anim_sources, + t6_player_anim_sources, t5_fx, t5_impact_fx, iw5_materials, diff --git a/crates/assets/src/session_load/common_walks.rs b/crates/assets/src/session_load/common_walks.rs index 63f5acb..a9f6410 100644 --- a/crates/assets/src/session_load/common_walks.rs +++ b/crates/assets/src/session_load/common_walks.rs @@ -545,6 +545,7 @@ pub(super) struct Iw5WeaponBundle { pub(super) fpv: FpvMeshBuild, pub(super) world_guns: WorldWeaponBuild, pub(super) xanims: XAnimBuild, + pub(super) player_anim_sources: asset_anim::PlayerAnimSources, pub(super) materials: MaterialCatalog, pub(super) stats_tables: Vec, @@ -611,6 +612,7 @@ pub(super) fn walk_iw5_weapon_bundle( fpv: census.fpv, world_guns: census.world_weapons, xanims: census.xanims, + player_anim_sources: census.player_anim_sources, materials: census.material_population, stats_tables: census.cac_tables, scene_models: census.scene_models, @@ -672,6 +674,7 @@ pub(super) fn walk_shared_iw5_common( fpv: census.fpv, world_guns: census.world_weapons, xanims: census.xanims, + player_anim_sources: census.player_anim_sources, materials: MaterialCatalog::default(), stats_tables: census.cac_tables, scene_models: census.scene_models, @@ -898,20 +901,35 @@ pub(super) fn walk_t6_weapon_bundle( Vec, Vec, Vec<(String, asset_material::material_images::ZoneUiImage)>, + asset_anim::PlayerAnimSources, ) { let mut report = Vec::new(); let root = match games_root_from_env() { Ok(root) => root, Err(error) => { report.push(format!("t6 weapons: {error}")); - return (WeaponBuild::default(), None, Vec::new(), report, Vec::new()); + return ( + WeaponBuild::default(), + None, + Vec::new(), + report, + Vec::new(), + Default::default(), + ); } }; let donor = match find_common_mp_for_envelope(&root, fastfile_t6::ZONE_VERSION_PC) { Ok(donor) => donor, Err(error) => { report.push(format!("t6 weapons: {error}")); - return (WeaponBuild::default(), None, Vec::new(), report, Vec::new()); + return ( + WeaponBuild::default(), + None, + Vec::new(), + report, + Vec::new(), + Default::default(), + ); } }; let stage = progress.begin_scoped(StageId::CommonAssets, "t6_weapons", None); @@ -924,7 +942,14 @@ pub(super) fn walk_t6_weapon_bundle( "t6 weapons: open {}: {error}", donor.path.display() )); - return (WeaponBuild::default(), None, Vec::new(), report, Vec::new()); + return ( + WeaponBuild::default(), + None, + Vec::new(), + report, + Vec::new(), + Default::default(), + ); } }; let tables = t6_class_tables(&root, &mut report); @@ -944,6 +969,7 @@ pub(super) fn walk_t6_weapon_bundle( tables, report, census.ui_images, + census.player_anim_sources, ) } diff --git a/crates/assets/src/session_load/match_walk.rs b/crates/assets/src/session_load/match_walk.rs index 7394f0b..b7b31d8 100644 --- a/crates/assets/src/session_load/match_walk.rs +++ b/crates/assets/src/session_load/match_walk.rs @@ -91,6 +91,8 @@ pub(super) async fn walk_prepared_match( impact_fx: common_impact, t5_xanims, t5_player_anim_sources, + iw5_player_anim_sources, + t6_player_anim_sources, t5_fx, t5_impact_fx, iw5_materials, @@ -185,8 +187,13 @@ pub(super) async fn walk_prepared_match( gaps, } = loaded; world.source_namespace = map_namespace; - if map_family == asset_core::FamilyId::T5 { - player_anim_sources = t5_player_anim_sources; + if player_anim_sources.family() != Some(map_family) { + player_anim_sources = match map_family { + asset_core::FamilyId::Iw5 => iw5_player_anim_sources, + asset_core::FamilyId::T5 => t5_player_anim_sources, + asset_core::FamilyId::T6 => t6_player_anim_sources, + _ => asset_anim::PlayerAnimSources::default(), + }; player_anim_sources.compile(); report.push(player_anim_sources.compile_report_line()); report.push(player_anim_sources.parse_report_line()); @@ -283,6 +290,7 @@ pub(super) async fn walk_prepared_match( }; let mut scripts = iw4_scripts; scripts.overlay(map_scripts); + scripts.insert_source("iw4l_maps/barrels", crate::map_scripts::BARRELS.to_owned()); scripts.insert_source( "iw4l_t6/equipment", crate::map_scripts::T6_EQUIPMENT.to_owned(), diff --git a/crates/audio/src/destructible_loops.rs b/crates/audio/src/destructible_loops.rs index b79b61d..457c26b 100644 --- a/crates/audio/src/destructible_loops.rs +++ b/crates/audio/src/destructible_loops.rs @@ -67,6 +67,10 @@ pub(crate) fn update( }); sources.desired.clear(); for (row, alias) in speaking { + let (ns, alias) = alias + .split_once(':') + .and_then(|(family, name)| AssetNamespace::parse(family).map(|ns| (ns, name))) + .unwrap_or((ns, alias)); let owner = row.owner.to_wire(); let changed = sources.live.get(&owner).is_none_or(|current| { current.alias != alias || current.namespace != ns || current.emitter != row.snd_ent diff --git a/crates/console/src/user_settings.rs b/crates/console/src/user_settings.rs index c77cf3e..3e89bd3 100644 --- a/crates/console/src/user_settings.rs +++ b/crates/console/src/user_settings.rs @@ -35,7 +35,10 @@ pub(crate) fn load_user_settings( }; persistence.path = Some(path.clone()); match fs::read_to_string(&path) { - Ok(source) => parse_settings(&source, &mut settings, &mut binds), + Ok(source) => { + parse_settings(&source, &mut settings, &mut binds); + persistence.last_payload = Some(source); + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { // Do not replace settings we could not read with session defaults. @@ -47,7 +50,9 @@ pub(crate) fn load_user_settings( } } settings.sanitize(); - persistence.last_payload = Some(serialize_settings(&settings, &binds)); + if persistence.last_payload.is_none() { + persistence.last_payload = Some(serialize_settings(&settings, &binds)); + } } pub(crate) fn consume_menu_binding( @@ -308,7 +313,7 @@ fn parse_into(value: &str, slot: &mut T) { fn serialize_settings(settings: &frame::GameSettings, binds: &KeyBinds) -> String { let safe_name = settings.player_name.replace(['\n', '\r', '='], " "); let mut lines = vec![ - "// IW4L user settings v2".to_owned(), + "// IW4L user settings v3".to_owned(), format!( "resolution={}x{}", settings.resolution.width, settings.resolution.height @@ -454,6 +459,22 @@ fn parse_settings(source: &str, settings: &mut frame::GameSettings, binds: &mut warn!("settings bind: {warning}"); } } + if !source + .lines() + .any(|line| line.trim() == "// IW4L user settings v3") + { + for (key, command) in [ + (KeyCode::KeyT, "chatmodepublic"), + (KeyCode::KeyY, "chatmodeteam"), + ] { + if let Some(id) = input_iw4::command_id_lookup(command) + && binds.get(BindButton::Key(key)).is_none() + && !binds.iter().any(|(_, bound)| bound == id) + { + binds.set(BindButton::Key(key), id); + } + } + } if !binds.has_pad_binds() { binds.apply_pad_layout(usize::from(settings.pad_layout.min(4))); } diff --git a/crates/fx_iw4/src/beam.rs b/crates/fx_iw4/src/beam.rs index 8a731d0..c114e52 100644 --- a/crates/fx_iw4/src/beam.rs +++ b/crates/fx_iw4/src/beam.rs @@ -74,7 +74,7 @@ pub fn beam_segment_count(length: f32, screw_dist: f32) -> u8 { if screw_dist < 1e-5 { return 1; } - let n = libm::roundf(length / screw_dist) as i32; + let n = (length / screw_dist * 8.0) as i32; n.clamp(1, i32::from(FX_BEAM_MAX_SEGMENTS)) as u8 } @@ -332,7 +332,7 @@ pub fn beam_generate_verts( let begin_c = beam_sample_color(&beam.colors, 0.0); verts[0] = FxBeamVert { xyz: sub(beam.begin, scale(flat, beam.begin_radius)), - uv: [0.0, 0.5], + uv: [0.0, 0.0], color: begin_c, }; let mut w = 1usize; @@ -349,12 +349,12 @@ pub fn beam_generate_verts( let c = beam_sample_color(&beam.colors, alpha); verts[w] = FxBeamVert { xyz: sub(base, scale(perp, radius)), - uv: [alpha, 0.0], + uv: [0.0, 1.0], color: c, }; verts[w + 1] = FxBeamVert { xyz: add(base, scale(perp, radius)), - uv: [alpha, 1.0], + uv: [1.0, 0.0], color: c, }; w += 2; @@ -362,7 +362,7 @@ pub fn beam_generate_verts( let end_c = beam_sample_color(&beam.colors, 1.0); verts[w] = FxBeamVert { xyz: add(beam.end, scale(flat, beam.end_radius)), - uv: [1.0, 0.5], + uv: [1.0, 1.0], color: end_c, }; let wrote_idx = write_beam_indices(seg_count, indices); diff --git a/crates/sim/src/chat.rs b/crates/sim/src/chat.rs index 80f2254..42ffe14 100644 --- a/crates/sim/src/chat.rs +++ b/crates/sim/src/chat.rs @@ -61,20 +61,11 @@ fn send_text( .position(|b| *b == 0) .unwrap_or(meta.name.len()); let name = clean(&String::from_utf8_lossy(&meta.name[..end])); - let state = match meta.lifecycle { - ClientLifecycle::Spectating => "(Spectator) ", - ClientLifecycle::Dead | ClientLifecycle::RespawnPending => "(Dead) ", - _ => "", - }; let text = clean(text); if text.trim().is_empty() { return false; } - let channel = if team_only { "(Team) " } else { "" }; - let line = format!( - "{state}{channel}{name}{}: {text}", - crate::HUD_PRINT_ARG_SEPARATOR - ); + let line = format!("{name}{}: {text}", crate::HUD_PRINT_ARG_SEPARATOR); let dead_chat = world .ecs() .get_resource::() diff --git a/crates/sim/src/script/host/entity_damage.rs b/crates/sim/src/script/host/entity_damage.rs index 649ee6b..0325695 100644 --- a/crates/sim/src/script/host/entity_damage.rs +++ b/crates/sim/src/script/host/entity_damage.rs @@ -320,13 +320,14 @@ pub(crate) fn destructible_effect( name: String, origin: [f32; 3], direction: [f32; 3], -) { + up: [f32; 3], +) -> Option { let now = super::players::now_ms(world); let Some(mut runtime) = world.get_resource_mut::() else { - return; + return None; }; let Ok(id) = runtime.create_entity(super::entities::EntityKind::Spawned, "script_model") else { - return; + return None; }; let forward = glam::Vec3::from_array(direction) .try_normalize() @@ -337,12 +338,19 @@ pub(crate) fn destructible_effect( name, origin, forward: forward.to_array(), - up: forward.any_orthonormal_vector().to_array(), + up, start_ms: Some(now as i32), repeat_ms: 0, cull_distance: 0.0, }, ); + Some(id) +} + +pub(crate) fn stop_destructible_effect(world: &mut World, id: u64) { + if let Some(mut runtime) = world.get_resource_mut::() { + runtime.delete_entity(id); + } } pub(crate) fn set_destructible_model( diff --git a/crates/sim/src/script/mod.rs b/crates/sim/src/script/mod.rs index 3e69565..fe256a7 100644 --- a/crates/sim/src/script/mod.rs +++ b/crates/sim/src/script/mod.rs @@ -53,5 +53,5 @@ pub(crate) use vm::state::{Frame, Thread, ThreadState, Waiter, WaiterKind}; pub(crate) use host::entity_damage::{ destructible_attacker, destructible_callback, destructible_debris, destructible_effect, - set_destructible_model, + set_destructible_model, stop_destructible_effect, }; diff --git a/crates/sim/src/t5_destructible.rs b/crates/sim/src/t5_destructible.rs index 616b070..943489f 100644 --- a/crates/sim/src/t5_destructible.rs +++ b/crates/sim/src/t5_destructible.rs @@ -8,6 +8,7 @@ pub(crate) struct State { definition: Arc, health: Vec, timers: Vec<(usize, usize, f32, i32)>, + effects: Vec<(usize, u64)>, initial_model: String, initial_hide_parts: xmodel_runtime::HidePartBits, } @@ -17,6 +18,7 @@ pub fn install(dobj: &mut AuthorityDObjState, definition: Arc health: definition.pieces.iter().map(|p| p.health as i16).collect(), definition, timers: Vec::new(), + effects: Vec::new(), initial_model: dobj.current_model.clone(), initial_hide_parts: dobj.semantic_state.hide_part_bits, }); @@ -178,10 +180,20 @@ fn publish( .as_ref() .and_then(|cap| cap.pose(&dobj.pose_request, dobj.world_from_model).ok()); let mut events = Vec::new(); + let mut effects = Vec::new(); + let mut stopped = Vec::new(); let mut debris = Vec::new(); for event in breaks { let piece = &definition.pieces[event.piece]; let st = &piece.stages[event.stage]; + state.effects.retain(|(piece, id)| { + if *piece == event.piece { + stopped.push(*id); + false + } else { + true + } + }); let bone = dobj.capability.as_ref().and_then(|cap| { cap.pose @@ -256,12 +268,17 @@ fn publish( if let Some(notify) = &st.break_notify { callbacks.push(notify.clone()); } - if let Some(fx) = &st.break_effect { - events.push(( - entity_iw4::EntityEventKind::PLAY_FX, + if let Some(fx) = st.break_effect.as_ref().filter(|_| { + !st.break_effect_looping + || piece.stage(event.piece, state.health[event.piece]) == Some(event.stage + 1) + }) { + effects.push(( + event.piece, + st.break_effect_looping, fx.clone(), origin, direction, + matrix.z_axis.truncate().normalize().to_array(), )); } if let Some(sound) = &st.break_sound { @@ -274,6 +291,9 @@ fn publish( } } update_hide_parts(dobj); + for id in stopped { + crate::script::stop_destructible_effect(world.ecs(), id); + } for (part, offset, half, velocity) in debris { crate::script::destructible_debris(world.ecs(), part, offset, half, velocity); } @@ -309,11 +329,21 @@ fn publish( ], ); } - for (kind, name, origin, direction) in events { - if kind == entity_iw4::EntityEventKind::PLAY_FX { - crate::script::destructible_effect(world.ecs(), name, origin, direction); - continue; + for (piece, looping, name, origin, direction, up) in effects { + if let Some(id) = + crate::script::destructible_effect(world.ecs(), name, origin, direction, up) + && looping + && let Some(state) = world + .entity_collision_capabilities_mut() + .iter_mut() + .find(|row| row.owner == owner) + .and_then(|row| row.dobj.as_mut()) + .and_then(|dobj| dobj.t5_destructible.as_mut()) + { + state.effects.push((piece, id)); } + } + for (kind, name, origin, direction) in events { let index = world.sound_alias_index(&name); world.push_entity_event( tick, @@ -528,6 +558,7 @@ pub(crate) fn restart(world: &mut FrameWorld) { .map(|p| p.health as i16) .collect(); state.timers.clear(); + state.effects.clear(); dobj.replace_model(&model, capability); dobj.semantic_state.hide_part_bits = hide; dobj.pose_request.hide_part_bits = hide; diff --git a/crates/ui/src/menu.rs b/crates/ui/src/menu.rs index 087cb8e..c27787b 100644 --- a/crates/ui/src/menu.rs +++ b/crates/ui/src/menu.rs @@ -88,6 +88,34 @@ pub fn install_frontend_menus(catalog: &mut asset_game::MenuCatalog) -> Result<( { item.choices = crate::CommunityServers::default().choices; } + let chat_bindings: Vec<_> = catalog + .get("pc_options_actions") + .into_iter() + .flat_map(|menu| &menu.items) + .filter(|item| { + matches!( + item.name.as_str(), + "public_chat_binding" + | "public_chat_label" + | "team_chat_binding" + | "team_chat_label" + ) + }) + .cloned() + .map(|mut item| { + item.rect.y = if item.name.starts_with("public_") { + 48.0 + } else { + 68.0 + }; + item.vis_exp = "1".into(); + item.disabled_exp = "0".into(); + item + }) + .collect(); + if let Some(menu) = catalog.menus.get_mut("options_multi") { + menu.items.extend(chat_bindings); + } let slider = catalog .get("pc_options_video") .and_then(|menu| { diff --git a/crates/xmodel_runtime/src/destructible.rs b/crates/xmodel_runtime/src/destructible.rs index ee2140d..1bb9748 100644 --- a/crates/xmodel_runtime/src/destructible.rs +++ b/crates/xmodel_runtime/src/destructible.rs @@ -25,6 +25,7 @@ pub struct T5DestructibleStage { pub max_time: f32, pub flags: u32, pub break_effect: Option, + pub break_effect_looping: bool, pub break_sound: Option, pub break_notify: Option, pub loop_sound: Option, diff --git a/docs/RUN.md b/docs/RUN.md index b38680f..9ab5d96 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -48,11 +48,13 @@ A hand-typed `quit`/`exit`/`disconnect` goes further: it jumps **every** hold, `wait 60s` included, and releases it. A `quit` written into `--cmds` does not — it is part of the script and waits its turn. -Public chat is **T**, team chat is **Y**. Controls → Actions can rebind both; +Public chat is **T**, team chat is **Y**. Multiplayer settings and Controls → +Actions can rebind both; `bind T chatmodepublic` / `bind Y chatmodeteam` also persist. Enter sends, Escape cancels; arrows, Home/End, Backspace/Delete edit the message. `say ` and `say_team ` send from the console. Chat lasts -eight seconds, shows five recent messages, and uses team colors for names. +eight seconds, shows five recent messages, and uses team colors for names with white +message text. Lines contain only `nickname: message`, without status prefixes. ## Verbs and traps