From efa20d65ec898cfc98aecfc2d087d5fe0954b9bb Mon Sep 17 00:00:00 2001 From: neptune Date: Fri, 2 Oct 2026 02:50:36 -0400 Subject: [PATCH] Make the Black Ops II tactical insertion a tactical with its own model The T6 tactical insertion sits in the tactical slot and throws with the tactical button beside the lethal: the class script is handed a smoke grenade standing for it, and its giving starts IW4's tactical insertion perk. The glow stick IW4's scripts plant for it carries the T6 planted model, drawn as a projectile mesh standing on end, and the thrown grenade is removed once it is planted. IW4's own tactical insertion chosen as the lethal is given as the equipment perk instead of a frag. Co-Authored-By: Claude Opus 5.5 --- crates/asset_game/src/lib.rs | 3 +- crates/asset_game/src/weapon_catalog.rs | 11 +- crates/asset_game/src/weapon_t6.rs | 24 +++ crates/assets/src/lane/t6.rs | 1 + .../assets/src/session_load/common_walks.rs | 11 +- crates/render_anim/src/occupancy/missile.rs | 72 +++++++- crates/sim/src/lib.rs | 9 + crates/sim/src/script/host/natives/engine.rs | 1 + crates/sim/src/script/host/natives/player.rs | 2 + crates/sim/src/script/host/players.rs | 154 +++++++++++++++++- crates/sim/src/script/host/weapons.rs | 7 + crates/sim/src/script/runtime/state.rs | 5 + 12 files changed, 294 insertions(+), 6 deletions(-) diff --git a/crates/asset_game/src/lib.rs b/crates/asset_game/src/lib.rs index a83f1ce..c9b37a3 100644 --- a/crates/asset_game/src/lib.rs +++ b/crates/asset_game/src/lib.rs @@ -31,7 +31,8 @@ mod weapon_catalog; mod weapon_families; mod weapon_t6; pub use weapon_t6::{ - MELEE_WEAPON as T6_MELEE_WEAPON, capture_t6_string_table, stand_in_for as t6_stand_in_for, + MELEE_WEAPON as T6_MELEE_WEAPON, capture_t6_string_table, + planted_model as t6_planted_model, stand_in_for as t6_stand_in_for, }; pub use arena::*; diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 369b696..da6cb49 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -444,6 +444,9 @@ pub enum CacOffhandBucket { Tactical, } +/// IW4's `smoke` offhand class, thrown with the tactical button. +const OFFHAND_CLASS_SMOKE: i32 = 2; + pub fn cac_offhand_bucket(offhand_class: i32) -> Option { match offhand_class { 1 | 4 | 5 => Some(CacOffhandBucket::Lethal), @@ -2474,7 +2477,8 @@ impl WeaponCatalog { .map(t6_model_name), projectile_model: weapon .def_asset_name(fastfile_t6::weapon::def::PROJECTILE_MODEL) - .map(t6_model_name), + .map(t6_model_name) + .or_else(|| crate::weapon_t6::planted_model(name).map(str::to_owned)), rocket_model: None, sz_xanims: t6_sz_xanims(weapon), // Paired by name when the rows are dressed (`X_dw` with `X_lh`). @@ -6580,6 +6584,11 @@ impl WeaponBuild { if facts.offhand_class != 0 && stand_in_facts.offhand_class != 0 { facts.offhand_class = stand_in_facts.offhand_class; } + // T6 tactical equipment throws with the tactical button whatever + // its stand-in is (IW4's tactical insertion is equipment). + if crate::weapon_t6::is_tactical_equipment(&own.name) { + facts.offhand_class = OFFHAND_CLASS_SMOKE; + } // A T6 riot shield bashes with the fire button. facts.fire_melees = facts.weap_type == weapon_iw4::WEAPTYPE_SHIELD; // T6 weapons carry no penetration multiplier: without the diff --git a/crates/asset_game/src/weapon_t6.rs b/crates/asset_game/src/weapon_t6.rs index 5b9e377..4764460 100644 --- a/crates/asset_game/src/weapon_t6.rs +++ b/crates/asset_game/src/weapon_t6.rs @@ -70,6 +70,30 @@ const STAND_INS: &[(&str, &str)] = &[ ("tactical_insertion", "flare_mp"), ]; +/// T6 tactical equipment whose stand-in is not a tactical grenade in IW4 +/// (`flare_mp` is IW4's tactical insertion, equipment there). +const TACTICAL_EQUIPMENT: &[&str] = &["tactical_insertion"]; + +/// Whether a T6 weapon is tactical equipment the IW4 stand-in's offhand +/// class would file as lethal. +pub fn is_tactical_equipment(name: &str) -> bool { + TACTICAL_EQUIPMENT.contains(&base_name(name)) +} + +/// T6 equipment whose scripts plant a model of their own, which its weapon +/// does not name: it stands for the thrown one. +const PLANTED_MODELS: &[(&str, &str)] = &[("tactical_insertion", "t6_wpn_tac_insert_world")]; + +/// The model a T6 weapon's scripts plant it as, when its weapon does not +/// name one. +pub fn planted_model(name: &str) -> Option<&'static str> { + let base = base_name(name); + PLANTED_MODELS + .iter() + .find(|(t6, _)| *t6 == base) + .map(|(_, model)| *model) +} + /// The T6 weapon whose knife and swings every gun's melee borrows when /// the gun has no melee clip of its own (T6 rifles and snipers do not). pub const MELEE_WEAPON: &str = "knife_mp"; diff --git a/crates/assets/src/lane/t6.rs b/crates/assets/src/lane/t6.rs index 67eb60a..b4bb56e 100644 --- a/crates/assets/src/lane/t6.rs +++ b/crates/assets/src/lane/t6.rs @@ -999,6 +999,7 @@ fn capture_content( (weapon.def_asset_array_name(def::GUN_XMODEL, 0), true), (weapon.def_asset_array_name(def::WORLD_MODEL, 0), false), (weapon.def_asset_name(def::PROJECTILE_MODEL), false), + (weapon.name().and_then(asset_game::t6_planted_model), false), ] { if let Some(name) = name { wanted diff --git a/crates/assets/src/session_load/common_walks.rs b/crates/assets/src/session_load/common_walks.rs index 9732417..9ee6ec3 100644 --- a/crates/assets/src/session_load/common_walks.rs +++ b/crates/assets/src/session_load/common_walks.rs @@ -983,7 +983,16 @@ pub(super) fn bind_t6_content( } else if model.view { &fpv.get(Iw4, weapons.gun_xmodel_of(id)?)?.material_keys } else { - &world.get(Iw4, weapons.world_model_of(id)?)?.material_keys + // A stand-in with no world model of its own (IW4's + // tactical insertion is planted by script) lends its + // first-person model's. + match weapons + .world_model_of(id) + .and_then(|name| world.get(Iw4, name)) + { + Some(gun) => &gun.material_keys, + None => &fpv.get(Iw4, weapons.gun_xmodel_of(id)?)?.material_keys, + } }; // The gun's (or arms') lit body: the material binding both a // colour and a normal map, not a sight, glow or decal. The diff --git a/crates/render_anim/src/occupancy/missile.rs b/crates/render_anim/src/occupancy/missile.rs index 1cd8d85..6481111 100644 --- a/crates/render_anim/src/occupancy/missile.rs +++ b/crates/render_anim/src/occupancy/missile.rs @@ -13,11 +13,14 @@ use anim_iw4::DOBJ_RADIUS_PARENT_ROOT; use render_scene::{ HostGfxScene, ModelLightingOwner, ModelLightingRequest, ModelLightingRequests, SmodelPassMaterial, TessMaterials, WorldModelLightingAtlas, WorldPresentFacts, - XModelSurfaceDraw, scene_quat_from_angles, + WorldScriptModelInstance, XModelSurfaceDraw, scene_quat_from_angles, }; pub const MISSILE_LIGHTING_Z_OFS: f32 = 4.0; +/// The pitch a planted weapon model stands at, its forward axis up. +const PLANTED_PITCH: f32 = -90.0; + #[derive(Resource, Default)] pub struct MissileOccupancy { pub rows: Vec, @@ -151,6 +154,7 @@ fn occupy_missile_scene_ents( mut scene_submissions: MessageWriter, cg_clock: Option>, local: Option>, + script_models: Query<(Entity, &WorldScriptModelInstance, &Transform, &Visibility)>, ) { occupancy.rows.clear(); let Some(snapshot) = presented.as_ref() else { @@ -254,6 +258,72 @@ fn occupy_missile_scene_ents( lighting_owner, }); } + // A script model carrying a weapon's thrown model (a foreign tactical + // insertion planted by IW4's scripts) is drawn as that weapon's + // projectile: the script model catalog cannot resolve the attachment, + // so the script model itself is not drawn. + for (entity, owner, transform, visibility) in &script_models { + if *visibility == Visibility::Hidden { + continue; + } + let Some(entnum) = owner.gentity_number.map(u32::from) else { + continue; + }; + let Some((namespace, name, entry)) = owner + .dobj_state + .composition + .models + .iter() + .skip(1) + .find_map(|attached| { + let weapon = sim::weapon_model_attachment(&attached.model)?; + let reg = weapons_reg?; + let name = reg.projectile_model_of(weapon)?; + let ns = reg + .namespace_of(weapon) + .unwrap_or(asset_core::AssetNamespace::Iw4); + Some((ns, name, catalog.get(ns, name)?)) + }) + else { + continue; + }; + if entry.skel.pose.is_none() { + continue; + } + let origin = transform.translation.to_array(); + // A weapon's model lies along its forward axis; planted, it stands + // on its end. + let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::ZYX); + let angles = [pitch.to_degrees() + PLANTED_PITCH, yaw.to_degrees(), roll.to_degrees()]; + let lighting_origin = missile_lighting_origin(origin); + scene_submissions.write(AnimDObjSceneSubmission { + render_fx_flags: 0, + has_tree: false, + origin, + lighting_origin, + radius: entry.skel.radius, + entnum, + quat: Some(scene_quat_from_angles(angles)), + occupy_model_n: 1, + models: vec![scene_skels.shared(name, &entry.skel, 0)], + hide_part_bits: [0; 6], + store_skin: true, + }); + let index = rows.len() + occupancy.rows.len(); + occupancy.rows.push(OccupiedMissile { + index, + id: None, + entnum: Some(entnum), + weapon: 0, + ignited: false, + origin, + angles, + name: name.to_owned(), + namespace, + lighting_origin, + lighting_owner: ModelLightingOwner::ScriptModel(entity), + }); + } } fn publish_missile_dobj_poses( diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 10d2f80..29777ac 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -126,6 +126,15 @@ pub use spawn::{ spawn_candidate_indices, spawn_candidate_indices_for, }; pub use step::phase_materialize_entity_dobjs; + +/// A script model attachment standing for a weapon's thrown model +/// (`#weapon:`), which the renderer draws from its own catalog. +pub const WEAPON_MODEL_PREFIX: &str = "#weapon:"; + +/// The weapon a [`WEAPON_MODEL_PREFIX`] attachment stands for. +pub fn weapon_model_attachment(model: &str) -> Option { + model.strip_prefix(WEAPON_MODEL_PREFIX)?.parse().ok() +} pub use world::{ ClientId, HitvolDumpRow, PendingLocalSound, PendingPlayerCardEvent, PendingPlayerCardKind, PendingPrint, PlayerKitCollision, SimBrush, SimClipBsp, SimClipCmodels, SimClipMesh, diff --git a/crates/sim/src/script/host/natives/engine.rs b/crates/sim/src/script/host/natives/engine.rs index 9b26e60..786292b 100644 --- a/crates/sim/src/script/host/natives/engine.rs +++ b/crates/sim/src/script/host/natives/engine.rs @@ -979,6 +979,7 @@ fn register_appearance(registry: &mut NativeRegistry) { let model = string(args, 0)?; let id = entity_id(world, receiver)?; runtime(world).set_object_field(id, "model", Value::string(&model)); + crate::script::host::players::dress_insertion_glow(world, id, &model); Ok(Value::Undefined) }); registry.register( diff --git a/crates/sim/src/script/host/natives/player.rs b/crates/sim/src/script/host/natives/player.rs index a0a7278..9f1cec5 100644 --- a/crates/sim/src/script/host/natives/player.rs +++ b/crates/sim/src/script/host/natives/player.rs @@ -1170,9 +1170,11 @@ fn register_inventory(registry: &mut NativeRegistry) { ); registry.register(Method, "giveweapon", |world, receiver, args| { let id = client_of(world, receiver)?; + let named = weapon_arg(world, args, 0)?; let weapon = player_weapon(world, id, args, 0)?; let akimbo = optional(args, 2, int)?.unwrap_or(0) != 0; script_player::give_weapon(&mut FrameWorld::from_world(world), id, weapon, akimbo)?; + super::super::players::give_carried_insertion(world, id.0, receiver, named)?; Ok(Value::Undefined) }); registry.register(Method, "takeweapon", |world, receiver, args| { diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index a54387c..bdeb9f3 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -375,6 +375,146 @@ fn bridge_offhand(world: &mut World, client: u32, weapon: u32) { bridge.push((stand_in, weapon)); } +/// IW4's tactical insertion, which its class script gives only as +/// equipment (the `specialty_tacticalinsertion` perk). +const INSERTION: &str = "flare_mp"; + +/// The special grenade a tactical insertion in the tactical slot is handed +/// to the class script as: one it accepts, given with the smoke class. +const INSERTION_CARRIER: &str = "smoke_grenade_mp"; + +const GIVE_PERK: &str = "maps/mp/perks/_perks::giveperk"; + +/// The model IW4's scripts plant a tactical insertion's glow stick with. +const INSERTION_GLOW_MODEL: &str = "mil_emergency_flare_mp"; + +/// How far from its thrower a tactical insertion's glow stick is planted: +/// at the thrower's last spot on the ground. +const INSERTION_PLANT_REACH: f32 = 256.0; + +/// IW4's scripts plant a thrown tactical insertion as a glow stick of their +/// own and leave the grenade lying; a foreign one is noted for the glow +/// stick to carry its model in the grenade's place. +pub(crate) fn note_insertion_throw( + world: &mut World, + client: u32, + script: u32, + native: u32, + grenade: u64, +) { + if script == native || FrameWorld::from_world(world).weapon_script_name(script) != INSERTION { + return; + } + let model: Arc = format!("{}{native}", crate::WEAPON_MODEL_PREFIX).into(); + world + .resource_mut::() + .thrown_insertions + .insert(client, (model, grenade)); +} + +/// A glow stick the scripts plant for a foreign tactical insertion, or put +/// where one stood, carries that insertion's model. +pub(crate) fn dress_insertion_glow(world: &mut World, object: u64, model: &str) { + if model != INSERTION_GLOW_MODEL { + return; + } + let origin = match world.resource_mut::().object_field(object, "origin") { + Value::Vector(origin) => origin, + _ => return, + }; + let near = |a: [f32; 3], b: [f32; 3], reach: f32| { + a.iter().zip(b).map(|(a, b)| (a - b) * (a - b)).sum::() <= reach * reach + }; + let placed = world + .resource::() + .insertion_spots + .iter() + .find(|(spot, _)| near(*spot, origin, 1.0)) + .map(|(_, model)| model.clone()); + let model = match placed { + Some(model) => model, + None => { + let throwers: Vec = world + .resource::() + .thrown_insertions + .keys() + .copied() + .collect(); + let frame = FrameWorld::from_world(world); + let thrower = throwers.into_iter().find(|&client| { + frame + .player(ClientId(client)) + .is_some_and(|ps| near(ps.origin, origin, INSERTION_PLANT_REACH)) + }); + let mut runtime = world.resource_mut::(); + let Some((model, grenade)) = + thrower.and_then(|client| runtime.thrown_insertions.remove(&client)) + else { + return; + }; + if runtime.entities.contains_key(&grenade) && !runtime.pending_deletes.contains(&grenade) + { + runtime.pending_deletes.push(grenade); + } + if runtime.insertion_spots.len() >= 32 { + runtime.insertion_spots.remove(0); + } + runtime.insertion_spots.push((origin, model.clone())); + model + } + }; + let mut runtime = world.resource_mut::(); + let Some(entity) = runtime.entities.get_mut(&object) else { + return; + }; + let tag: Arc = "tag_origin".into(); + if !entity.attachments.iter().any(|(m, _)| *m == model) { + entity.attachments.push((model, tag)); + } +} + +/// A tactical insertion chosen as the tactical is given in its carrier's +/// place too. +fn bridge_insertion_carrier(world: &mut World, client: u32, weapon: u32) { + let frame = FrameWorld::from_world(world); + let Some(carrier) = frame.weapon_index_by_script_name(INSERTION_CARRIER) else { + return; + }; + let stand_in = offhand_stand_in(world, weapon).unwrap_or(weapon); + if weapon == 0 || FrameWorld::from_world(world).weapon_script_name(stand_in) != INSERTION { + return; + } + let mut runtime = world.resource_mut::(); + let bridge = runtime.weapon_bridge.entry(client).or_default(); + bridge.retain(|(from, _)| *from != carrier); + bridge.push((carrier, weapon)); +} + +/// When the class script gives a tactical insertion's carrier, the perk +/// that plants it is given as well: the class script never gives it +/// beside a lethal. +pub(crate) fn give_carried_insertion( + world: &mut World, + client: u32, + receiver: &Value, + weapon: u32, +) -> Result<(), String> { + let frame = FrameWorld::from_world(world); + if frame.weapon_script_name(weapon) != INSERTION_CARRIER + || bridged_weapon(world, client, weapon) == weapon + { + return Ok(()); + } + crate::script::start( + world, + GIVE_PERK, + receiver.clone(), + vec![Value::String("specialty_tacticalinsertion".into())], + ) + .map(|_| ()) + .map_err(|fault| fault.to_string()) +} + pub(crate) fn bridged_weapon(world: &World, client: u32, weapon: u32) -> u32 { world .resource::() @@ -429,6 +569,7 @@ pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassD } bridge_offhand(world, client, class.lethal); bridge_offhand(world, client, class.tactical); + bridge_insertion_carrier(world, client, class.tactical); let data = class_profile_data(world, class); push_answer( world, @@ -482,7 +623,13 @@ fn class_profile_data(world: &mut World, class: &crate::ClassDef) -> Vec<(String put(format!("{key}.attachment.1"), &attachments[1]); put(format!("{key}.camo"), "none"); } - put(format!("{prefix}.perks.0"), &name(lethal)); + // IW4's class script takes the tactical insertion as equipment only as + // the perk that gives its flare (any other name is a frag). + let equipment = match name(lethal) { + insertion if insertion == INSERTION => "specialty_tacticalinsertion".to_owned(), + other => other, + }; + put(format!("{prefix}.perks.0"), &equipment); let mut perks = ["specialty_null"; 3]; for id in class.perks { if let (Some(slot), Some(perk)) = ( @@ -501,7 +648,10 @@ fn class_profile_data(world: &mut World, class: &crate::ClassDef) -> Vec<(String &class.deathstreak }; put(format!("{prefix}.perks.4"), deathstreak); - let tactical = name(tactical); + let tactical = match name(tactical) { + insertion if insertion == INSERTION => INSERTION_CARRIER.to_owned(), + other => other, + }; put( format!("{prefix}.specialgrenade"), tactical.strip_suffix("_mp").unwrap_or(&tactical), diff --git a/crates/sim/src/script/host/weapons.rs b/crates/sim/src/script/host/weapons.rs index 7098002..70e6f36 100644 --- a/crates/sim/src/script/host/weapons.rs +++ b/crates/sim/src/script/host/weapons.rs @@ -322,6 +322,13 @@ fn adopt_fired(world: &mut World) { } }; let weapon = super::players::script_weapon(world, projectile.owner.0, projectile.weapon); + super::players::note_insertion_throw( + world, + projectile.owner.0, + weapon, + projectile.weapon, + object, + ); let name = weapon_name(world, weapon); raise(world, player, notify, vec![Value::Object(object), name]); } diff --git a/crates/sim/src/script/runtime/state.rs b/crates/sim/src/script/runtime/state.rs index b028817..54a9649 100644 --- a/crates/sim/src/script/runtime/state.rs +++ b/crates/sim/src/script/runtime/state.rs @@ -48,6 +48,11 @@ pub(crate) struct Runtime { pub(crate) menu_answers: BTreeMap>, pub(crate) personal_classes: BTreeMap<(u32, u32), crate::ClassDef>, pub(crate) weapon_bridge: BTreeMap>, + /// The model and grenade of each client's foreign tactical insertion + /// thrown and not yet planted; see `players::dress_insertion_glow`. + pub(crate) thrown_insertions: BTreeMap, u64)>, + /// Where foreign tactical insertions were planted, with their model. + pub(crate) insertion_spots: Vec<([f32; 3], Arc)>, pub(crate) disconnects: std::collections::BTreeSet, pub(crate) kicks: BTreeMap, pub(crate) joined: std::collections::BTreeSet,