From 0987b48fb77b11b39a63e7d60256ab2a02fa116e Mon Sep 17 00:00:00 2001 From: neptune Date: Thu, 1 Oct 2026 16:46:34 -0400 Subject: [PATCH] Add Black Ops II weapon attachments (visible models) Every attachment a T6 weapon offers, alone or in a compatible pair, is a weapon row (`an94_acog_grip`) built from the base row and the weapon's WeaponAttachmentUnique assets: the attachment's first-person and world models at the weapon's own placement, hidden gun bones, clip overrides, fire sounds and alternate weapon. Stat-only attachments make rows that change nothing shown yet. - fastfile_t6: AttachmentUniqueView; loaded assets record the asset each pointer field named at load time, since walk memory is reused. - Each attachment model is captured per weapon (`model@unique`) with its offset as root rest and the gun bone it hangs from (ModelSkel::mount_tag). - T6 guns hide parts by collapsing bones in skinning: their sights share the body's surfaces. - Bare T6 rows hide what their `none` unique hides. - The full T6 attachment table loads; stats-table names like `acog_sniper` map to `acog`. Co-Authored-By: Claude Opus 5.5 --- crates/asset_game/src/fpv_assembly.rs | 46 ++- crates/asset_game/src/weapon_catalog.rs | 365 +++++++++++++++++- crates/asset_game/src/weapon_families.rs | 23 +- crates/asset_model/src/fpv_catalog.rs | 9 +- crates/asset_model/src/model_skel.rs | 10 + crates/asset_model/src/model_skel_t6.rs | 1 + crates/assets/src/lane/t6.rs | 22 +- .../assets/src/session_load/common_cache.rs | 5 + .../assets/src/session_load/common_walks.rs | 25 +- crates/fastfile_t6/src/walk.rs | 49 ++- crates/fastfile_t6/src/weapon.rs | 188 +++++++++ crates/render_anim/src/anim/fpv_rig.rs | 13 +- 12 files changed, 708 insertions(+), 48 deletions(-) diff --git a/crates/asset_game/src/fpv_assembly.rs b/crates/asset_game/src/fpv_assembly.rs index 94b6f37..4235907 100644 --- a/crates/asset_game/src/fpv_assembly.rs +++ b/crates/asset_game/src/fpv_assembly.rs @@ -44,6 +44,18 @@ pub struct FpvAssembly { pub paired_bones: usize, pub combined_hands: bool, pub tags: FpvAssemblyTags, + /// Gun bones whose vertices fold away (see [`FpvHideMode::Bones`]). + pub collapsed_bones: Vec, +} + +/// How a gun hides its hide tags. IW4 guns keep hideable parts in +/// surfaces of their own and skip those surfaces; a T6 gun's sights share +/// its body's surfaces, and T6 folds the hidden bones' vertices away. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum FpvHideMode { + #[default] + Surfaces, + Bones, } #[derive(Debug)] @@ -72,6 +84,7 @@ pub struct FpvAssemblyKey { pub rocket: Option, pub knife: Option, pub hide_tags: Vec, + pub hide_mode: FpvHideMode, } #[derive(Clone, Debug)] @@ -117,6 +130,7 @@ impl FpvAssembly { rocket: bool, knife: Option, hide_tags: &[String], + hide_mode: FpvHideMode, ) -> Result { let pose_of = |model: FpvMeshIndex| -> Result<&ModelPoseSrc, FpvAssemblyError> { catalog @@ -203,18 +217,31 @@ impl FpvAssembly { tracker_light: tag("tag_motion_tracker_fx"), }; let paired_bones = specs[0].0.num_bones + specs[1].0.num_bones; + let mut collapsed_bones = Vec::new(); let parts = parts .into_iter() .zip(&dobj.models) - .map(|((model, role, _), slot)| FpvAssemblyPart { - model, - role, - bone_base: slot.base, - hide: if role == FpvPartRole::Gun { - hide_words(catalog, model, hide_tags) - } else { - None - }, + .map(|((model, role, _), slot)| { + let hide = (role == FpvPartRole::Gun) + .then(|| hide_words(catalog, model, hide_tags)) + .flatten(); + let hide = match (hide_mode, hide) { + (FpvHideMode::Bones, Some(words)) => { + collapsed_bones.extend( + (0..slot.bone_count) + .filter(|&bone| words[bone >> 5] & (0x8000_0000u32 >> (bone & 31)) != 0) + .map(|bone| slot.base + bone), + ); + None + } + (_, hide) => hide, + }; + FpvAssemblyPart { + model, + role, + bone_base: slot.base, + hide, + } }) .collect(); Ok(Self { @@ -225,6 +252,7 @@ impl FpvAssembly { paired_bones, combined_hands: mounts.secondary_gun.is_some(), tags, + collapsed_bones, }) } diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 5e4fb3f..2985a97 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -481,6 +481,11 @@ pub struct CatalogWeapon { /// Models a T6 weapon attaches to its first-person and world guns (its /// magazine, a sniper's scope). pub attached_models: [Vec; 2], + /// Of those, the magazine (attachment slot 6), which a fast-mag + /// attachment replaces. + pub t6_clip_models: [Option; 2], + /// What each T6 attachment the weapon takes does to it. + pub t6_attachments: Vec, pub iw5_reload_overrides: Vec, pub iw5_anim_overrides: Vec, pub iw5_fx_overrides: Vec, @@ -1376,6 +1381,8 @@ impl WeaponCatalog { scope_rows: Default::default(), iw5_attachment_slots: std::array::from_fn(|_| None), attached_models: Default::default(), + t6_clip_models: Default::default(), + t6_attachments: Vec::new(), iw5_reload_overrides: Vec::new(), iw5_anim_overrides: Vec::new(), iw5_fx_overrides: Vec::new(), @@ -2134,6 +2141,8 @@ impl WeaponCatalog { scope_rows, iw5_attachment_slots, attached_models: Default::default(), + t6_clip_models: Default::default(), + t6_attachments: Vec::new(), iw5_reload_overrides, iw5_anim_overrides: leftover_anim_overrides, iw5_fx_overrides, @@ -2271,6 +2280,8 @@ impl WeaponCatalog { scope_rows: Default::default(), iw5_attachment_slots: std::array::from_fn(|_| None), attached_models: Default::default(), + t6_clip_models: Default::default(), + t6_attachments: Vec::new(), iw5_reload_overrides: Vec::new(), iw5_anim_overrides: Vec::new(), iw5_fx_overrides: Vec::new(), @@ -2406,6 +2417,15 @@ impl WeaponCatalog { scope_rows: Default::default(), iw5_attachment_slots: std::array::from_fn(|_| None), attached_models: [true, false].map(|view| t6_attached_models(weapon, view)), + t6_clip_models: [true, false].map(|view| { + weapon + .attached_model(T6_CLIP_SLOT, view) + .map(|(name, _, _)| t6_model_name(name)) + }), + t6_attachments: weapon + .attachment_uniques() + .filter_map(|unique| capture_t6_attachment(unique)) + .collect(), iw5_reload_overrides: Vec::new(), iw5_anim_overrides: Vec::new(), iw5_fx_overrides: Vec::new(), @@ -2777,6 +2797,13 @@ pub const T6_XANIM_PREFIX: &str = "t6_"; /// for T5, named with [`T6_XANIM_PREFIX`]. A T6 row keeps these only when it shows its own first-person /// model, see [`WeaponBuild::dress_t6_stand_ins`]. fn t6_sz_xanims(w: fastfile_t6::weapon::WeaponView<'_>) -> [Option; WEAPON_ANIM_SLOTS] { + t6_sz_xanims_by(|slot| w.xanim(slot)) +} + +/// [`t6_sz_xanims`] over any T6 `szXAnims` (a weapon's or an attachment's). +fn t6_sz_xanims_by<'a>( + xanim: impl Fn(u32) -> Option<&'a str>, +) -> [Option; WEAPON_ANIM_SLOTS] { use fastfile_t6::weapon::weap_anim as t6_anim; const PAIRS: [(usize, usize); 33] = [ (t6_anim::IDLE, weap_anim::IDLE), @@ -2820,8 +2847,7 @@ fn t6_sz_xanims(w: fastfile_t6::weapon::WeaponView<'_>) -> [Option; WEAP for (src, dst) in PAIRS { // `FIRE_INTRO` stands in for `FIRE` only where there is no `FIRE`. if out[dst].is_none() { - out[dst] = w - .xanim(src as u32) + out[dst] = xanim(src as u32) .filter(|name| !name.is_empty()) .map(|name| format!("{T6_XANIM_PREFIX}{}", name.to_ascii_lowercase())); } @@ -3590,6 +3616,120 @@ fn capture_t6_sounds(w: fastfile_t6::weapon::WeaponView<'_>) -> WeaponSoundAlias /// The models a T6 weapon attaches to its first-person (`view`) or world /// gun, by slot. +/// The `attachViewModel` slot T6 weapons hold their magazine in. +const T6_CLIP_SLOT: u32 = 6; + +/// One T6 attachment (or authored pair of attachments) as one weapon +/// takes it, read from its `WeaponAttachmentUnique`. +#[derive(Clone, Debug)] +pub struct T6Attachment { + /// `au_an94_acog`, `au_an94_acog+grip`. + pub name: String, + /// The attachment table's index of the attachment (of a pair, the one + /// it is filed under). + pub kind: u32, + /// For a pair, `1 << kind` of both; zero for one attachment. + pub mask: u32, + /// The weapon the attachment switches to (`gl_an94_mp`). + pub alt_weapon: Option, + /// First-person and world models, named as their placed copies (see + /// [`t6_attachment_models`]). + pub models: [Vec; 2], + /// Gun bones the attachment hides (iron sights under an optic). + pub hide_tags: Vec, + /// Clips the attachment plays instead of the weapon's, in IW4 slots. + pub xanims: [Option; WEAPON_ANIM_SLOTS], + pub fire_sound: Option, + pub fire_sound_player: Option, + /// Removes the weapon's own attached models (a sniper's scope) but its + /// magazine. + pub disable_base_attachment: bool, + /// Removes the weapon's magazine (a fast mag carries its own). + pub disable_base_clip: bool, +} + +/// A T6 attachment model placed for one weapon: the name its copy is +/// captured under, the model it copies, the gun bone it hangs from +/// (`None` for the root) and its offset and `(pitch, yaw, roll)` there. +pub struct T6AttachmentModel { + pub copy: String, + pub model: String, + pub tag: Option, + pub offset: [f32; 3], + pub angles: [f32; 3], +} + +/// The models `unique` hangs on the first-person (`view`) or world gun. +/// The same model sits differently on every weapon, so each weapon's is a +/// copy of its own, named `model@unique`. +pub fn t6_attachment_models( + unique: fastfile_t6::weapon::AttachmentUniqueView<'_>, + view: bool, +) -> Vec { + let Some(owner) = unique.name() else { + return Vec::new(); + }; + unique + .models(view) + .into_iter() + .flatten() + .map(|(model, tag, offset, angles)| { + let model = t6_model_name(model); + T6AttachmentModel { + copy: format!("{model}@{owner}"), + model, + tag: tag.map(str::to_owned), + offset, + angles, + } + }) + .collect() +} + +fn capture_t6_attachment(unique: fastfile_t6::weapon::AttachmentUniqueView<'_>) -> Option { + use fastfile_t6::weapon::unique as u; + let name = unique.name()?.to_owned(); + Some(T6Attachment { + kind: unique.attachment_type(), + mask: unique.combined_mask(), + alt_weapon: unique.alt_weapon().map(str::to_owned), + models: [true, false].map(|view| { + t6_attachment_models(unique, view) + .into_iter() + .map(|model| model.copy) + .collect() + }), + hide_tags: unique.hide_tags().map(str::to_owned).collect(), + xanims: t6_sz_xanims_by(|slot| unique.xanim(slot)), + fire_sound: unique.sound(u::FIRE_SOUND).map(str::to_owned), + fire_sound_player: unique.sound(u::FIRE_SOUND_PLAYER).map(str::to_owned), + disable_base_attachment: unique.flag(u::DISABLE_BASE_ATTACHMENT), + disable_base_clip: unique.flag(u::DISABLE_BASE_CLIP), + name, + }) +} + +/// Every clip a weapon's attachments name, as the zone names them. +pub fn t6_attachment_xanim_names(w: fastfile_t6::weapon::WeaponView<'_>) -> Vec { + w.attachment_uniques() + .flat_map(|unique| { + (0..fastfile_t6::weapon::variant::XANIM_COUNT) + .filter_map(move |slot| unique.xanim(slot)) + .map(str::to_ascii_lowercase) + }) + .collect() +} + +/// The fire sounds a weapon's attachments name (a silencer's). +pub fn t6_attachment_sound_names(w: fastfile_t6::weapon::WeaponView<'_>) -> Vec { + use fastfile_t6::weapon::unique as u; + w.attachment_uniques() + .flat_map(|unique| [u::FIRE_SOUND, u::FIRE_SOUND_PLAYER].map(|off| unique.sound(off))) + .flatten() + .map(str::to_owned) + .collect() +} + fn t6_attached_models(weapon: fastfile_t6::weapon::WeaponView<'_>, view: bool) -> Vec { (0..fastfile_t6::weapon::variant::ATTACH_MODEL_COUNT) .filter_map(|slot| weapon.attached_model(slot, view)) @@ -5493,6 +5633,11 @@ struct WeaponRow { attachment_view_models: Vec, attachment_world_models: Vec, + /// See [`CatalogWeapon::t6_clip_models`] and + /// [`CatalogWeapon::t6_attachments`]. + t6_clip_models: [Option; 2], + t6_attachments: Vec, + iw5_configuration: Option<(u32, Iw5AttachmentSelection)>, prepared_attachments: Vec, @@ -5581,6 +5726,8 @@ impl Default for WeaponRow { hide_tags: Vec::new(), attachment_view_models: Vec::new(), attachment_world_models: Vec::new(), + t6_clip_models: Default::default(), + t6_attachments: Vec::new(), iw5_configuration: None, prepared_attachments: Vec::new(), iw5_attachment_slots: std::array::from_fn(|_| None), @@ -5667,6 +5814,104 @@ pub struct Iw5PreparationCensus { pub refused: Vec, } +/// `base` wearing the T6 attachments of `kinds`: each one's models, +/// hidden gun bones, clips and fire sound, and the clips of the authored +/// pair when there is one. An attachment the weapon has no unique for +/// (FMJ, fast ADS) changes nothing it shows. `None` for a weapon without +/// T6 attachments. +fn compose_t6_configuration(base: &WeaponRow, kinds: &[u32], name: String) -> Option { + if base.t6_attachments.is_empty() { + return None; + } + let singles: Vec<&T6Attachment> = kinds + .iter() + .filter_map(|&kind| { + base.t6_attachments + .iter() + .find(|attachment| attachment.kind == kind && attachment.mask == 0) + }) + .collect(); + let mask = kinds.iter().fold(0u32, |mask, &kind| mask | 1u32.checked_shl(kind).unwrap_or(0)); + let pair = (kinds.len() > 1) + .then(|| base.t6_attachments.iter().find(|attachment| attachment.mask == mask)) + .flatten(); + let mut row = base.clone(); + row.name = name; + row.t6_attachments = Vec::new(); + for (side, models) in [&mut row.attachment_view_models, &mut row.attachment_world_models] + .into_iter() + .enumerate() + { + let clip = base.t6_clip_models[side].as_ref(); + let is_clip = |model: &String| clip == Some(model); + if singles.iter().any(|a| a.disable_base_attachment) { + models.retain(|model| is_clip(model)); + } + if singles.iter().any(|a| a.disable_base_clip) { + models.retain(|model| !is_clip(model)); + } + for attachment in &singles { + models.extend(attachment.models[side].iter().cloned()); + } + } + // An attachment's clip set repeats the weapon's where it changes + // nothing; only its own clips replace the weapon's, so one + // attachment's set does not undo another's. + for attachment in singles.iter().copied().chain(pair) { + for (slot, clip) in attachment.xanims.iter().enumerate() { + if let Some(clip) = clip + && base.sz_xanims[slot].as_ref() != Some(clip) + { + row.sz_xanims[slot] = Some(clip.clone()); + } + } + } + for attachment in &singles { + row.hide_tags.extend(attachment.hide_tags.iter().cloned()); + if let Some(sound) = &attachment.fire_sound { + row.sounds.fire = Some(sound.clone()); + } + if let Some(sound) = &attachment.fire_sound_player { + row.sounds.fire_player = Some(sound.clone()); + } + if let Some(alt) = &attachment.alt_weapon { + row.alternate_weapon = Some(alt.clone()); + } + } + Some(row) +} + +/// What [`WeaponBuild::prepare_t6_configurations`] did. +#[derive(Clone, Debug, Default)] +pub struct T6PreparationCensus { + pub prepared: usize, + /// Selections of an attachment the T6 table does not name, or of a + /// weapon without T6 attachments. + pub refused: usize, +} + +/// The T6 attachment table's index of each attachment, by name: the +/// `eAttachment` a weapon's attachment uniques are filed under. +fn t6_attachment_kinds( + tables: &[(crate::AssetNamespace, crate::CapturedStringTable)], +) -> HashMap { + tables + .iter() + .filter(|(namespace, table)| { + *namespace == crate::AssetNamespace::T6 + && table.name.eq_ignore_ascii_case("mp/attachmentTable.csv") + }) + .flat_map(|(_, table)| { + (1..table.rows as i32) + .filter(|&row| table.cell(row, 2) == "attachment") + .filter_map(|row| { + Some((table.cell(row, 4).to_ascii_lowercase(), table.cell(row, 0).parse().ok()?)) + }) + .collect::>() + }) + .collect() +} + /// The knife and swings a T6 gun without melee clips borrows (see /// [`crate::T6_MELEE_WEAPON`]). #[derive(Clone, Debug)] @@ -5727,6 +5972,69 @@ impl WeaponBuild { self.family_tables = tables; } + /// A row for every T6 family's attachment and compatible pair of + /// attachments: the base row wearing what each attachment's unique + /// does to it (see [`compose_t6_configuration`]). + pub fn prepare_t6_configurations(&mut self) -> T6PreparationCensus { + let families = crate::WeaponFamilies::build(&self.family_tables, &self.registry); + let kinds = t6_attachment_kinds(&self.family_tables); + let mut census = T6PreparationCensus::default(); + let mut prepared = Vec::new(); + for (base_id, selection) in families.candidate_selections(crate::AssetNamespace::T6) { + if selection.attachments.is_empty() { + continue; + } + let Some(family) = selection.family.as_ref().and_then(|key| families.family(key)) else { + continue; + }; + let attachments = families.normalize(crate::AssetNamespace::T6, &selection.attachments); + let name = families.configuration_name(family, &attachments); + // Dual wield is a weapon pair of its own (`fiveseven_dw`). + if self + .registry + .by_namespaced + .contains_key(&(crate::AssetNamespace::T6, name.clone())) + { + continue; + } + let kinds: Option> = attachments.iter().map(|a| kinds.get(a).copied()).collect(); + match kinds.and_then(|kinds| { + compose_t6_configuration(&self.registry.rows[base_id as usize], &kinds, name) + }) { + Some(mut row) => { + row.prepared_attachments = attachments.clone(); + prepared.push(( + base_id, + crate::WeaponSelection { + attachments, + ..selection + }, + row, + )); + } + None => census.refused += 1, + } + } + census.prepared = prepared.len(); + for (base_id, selection, row) in prepared { + self.registry + .configurations + .insert(selection, self.registry.rows.len() as u32); + let slots = self + .combat_slots + .get(base_id as usize) + .copied() + .unwrap_or_default(); + self.combat_slots + .resize(self.registry.rows.len(), CombatFxSlots::default()); + self.combat_slots.push(slots); + self.registry.rows.push(row); + } + self.registry.rebuild_name_maps(); + self.registry.revision = mint_weapon_revision(); + census + } + pub fn prepare_iw5_configurations(&mut self) -> Iw5PreparationCensus { let families = crate::WeaponFamilies::build(&self.family_tables, &self.registry); let mut census = Iw5PreparationCensus::default(); @@ -6060,6 +6368,38 @@ impl WeaponBuild { sounds.notetrack_convention = dressed.sounds.notetrack_convention; } dressed.sounds = sounds; + // A gun of its own hides what T6's bare weapon hides (a + // sniper's iron sights under its scope), not the stand-in's tags. + if own_gun { + dressed.hide_tags = own + .t6_attachments + .iter() + .find(|attachment| attachment.kind == 0 && attachment.mask == 0) + .map(|bare| bare.hide_tags.clone()) + .unwrap_or_default(); + } + // The attachments keep what reached the catalogs; their clips + // only where the row plays its own. + dressed.t6_clip_models = [ + own.t6_clip_models[0].clone().filter(|name| own_view(name)), + own.t6_clip_models[1].clone().filter(|name| own_world(name)), + ]; + dressed.t6_attachments = own + .t6_attachments + .iter() + .cloned() + .map(|mut attachment| { + attachment.models[0].retain(|name| own_gun && own_view(name)); + attachment.models[1].retain(|name| own_world(name)); + attachment.xanims = attachment + .xanims + .map(|name| name.filter(|name| hands.is_some() && own_anim(name))); + for sound in [&mut attachment.fire_sound, &mut attachment.fire_sound_player] { + *sound = sound.take().filter(|name| own_sound(name)); + } + attachment + }) + .collect(); dressed.name = own.name; dressed.namespace = crate::AssetNamespace::T6; dressed.alternate_weapon = own.alternate_weapon; @@ -6433,6 +6773,11 @@ impl WeaponBuild { .chain(&row.sz_xanim_left_edges) .filter_map(|edge| edge.bound_index()) .collect(); + let hide_mode = if row.namespace == crate::AssetNamespace::T6 { + crate::FpvHideMode::Bones + } else { + crate::FpvHideMode::Surfaces + }; let knife_model = row.knife_xmodel.as_deref().map(|name| { fpv_model_edge(Some(name), row.namespace.content(), fpv) .bound_index() @@ -6453,12 +6798,15 @@ impl WeaponBuild { .flatten(), knife, hide_tags: hide_tags.clone(), + hide_mode, }; shared .entry(key) .or_insert_with(|| { census.built += 1; - crate::FpvAssembly::build(fpv, hands, mounts, rocket, knife, &hide_tags) + crate::FpvAssembly::build( + fpv, hands, mounts, rocket, knife, &hide_tags, hide_mode, + ) .map(Arc::new) .map_err(|error| error.to_string()) }) @@ -6547,8 +6895,13 @@ impl WeaponBuild { .iter() .map(|edge| { if on_gun_root { - edge.bound_index()?; - return gun?.skel.bone_names.first().cloned(); + // The gun bone a T6 attachment names, else the root. + let attachment = catalog.get_at(edge.bound_index()?)?; + let bones = &gun?.skel.bone_names; + let tag = attachment.skel.mount_tag.as_deref().and_then(|tag| { + bones.iter().find(|bone| bone.eq_ignore_ascii_case(tag)) + }); + return tag.or(bones.first()).cloned(); } let root = catalog .get_at(edge.bound_index()?)? @@ -6877,6 +7230,8 @@ impl WeaponBuild { hide_tags: entry.hide_tags, attachment_view_models: entry.attached_models[0].clone(), attachment_world_models: entry.attached_models[1].clone(), + t6_clip_models: entry.t6_clip_models, + t6_attachments: entry.t6_attachments, iw5_configuration: None, prepared_attachments: Vec::new(), iw5_attachment_slots: entry.iw5_attachment_slots, diff --git a/crates/asset_game/src/weapon_families.rs b/crates/asset_game/src/weapon_families.rs index 6dadd96..9d46dca 100644 --- a/crates/asset_game/src/weapon_families.rs +++ b/crates/asset_game/src/weapon_families.rs @@ -437,6 +437,17 @@ impl WeaponFamilies { .cell(row, 8) .split_ascii_whitespace() .map(str::to_ascii_lowercase) + // T6 names an attachment after the per-class asset + // that carries it (`acog_sniper`, `reflex_pistol`). + .map(|name| match name.rsplit_once('_') { + Some((attachment, _)) + if namespace == AssetNamespace::T6 + && !known.attachments.contains_key(&name) => + { + attachment.to_owned() + } + _ => name, + }) .collect(), ), }; @@ -558,7 +569,7 @@ impl WeaponFamilies { out } - fn configuration_name(&self, family: &WeaponFamily, attachments: &[String]) -> String { + pub(crate) fn configuration_name(&self, family: &WeaponFamily, attachments: &[String]) -> String { if attachments.is_empty() { return family.key.base.clone(); } @@ -607,10 +618,16 @@ impl WeaponFamilies { } pub(crate) fn iw5_candidate_selections(&self) -> Vec<(u32, WeaponSelection)> { + self.candidate_selections(AssetNamespace::Iw5) + } + + /// Every loaded family of `namespace` bare, with each attachment it + /// offers, and with each compatible pair of them. + pub(crate) fn candidate_selections(&self, namespace: AssetNamespace) -> Vec<(u32, WeaponSelection)> { let mut families: Vec<&WeaponFamily> = self .families .iter() - .filter(|family| family.key.namespace == AssetNamespace::Iw5 && family.base.is_some()) + .filter(|family| family.key.namespace == namespace && family.base.is_some()) .collect(); families.sort_by_key(|family| family.key.asset_key()); let mut out = Vec::new(); @@ -630,7 +647,7 @@ impl WeaponFamilies { WeaponSelection::with(family.key.clone(), std::slice::from_ref(name)), )); for other in &names[i + 1..] { - if self.compatible(AssetNamespace::Iw5, name, other) { + if self.compatible(namespace, name, other) { out.push(( base, WeaponSelection::with( diff --git a/crates/asset_model/src/fpv_catalog.rs b/crates/asset_model/src/fpv_catalog.rs index 53c7a57..fe4eaf7 100644 --- a/crates/asset_model/src/fpv_catalog.rs +++ b/crates/asset_model/src/fpv_catalog.rs @@ -657,9 +657,16 @@ pub fn plan_fpv_mounts( .iter() .any(|name| name.eq_ignore_ascii_case(root)) }); + // A T6 attachment hangs from the gun bone it names, else the root. let gun_root = || { + let tag = skel.mount_tag.as_deref().and_then(|tag| { + gun_skel + .bone_names + .iter() + .find(|name| name.eq_ignore_ascii_case(tag)) + }); on_gun_root - .then(|| gun_skel.bone_names.first().map(|root| (1, root.as_str()))) + .then(|| tag.or(gun_skel.bone_names.first()).map(|bone| (1, bone.as_str()))) .flatten() }; let joint = if on_gun_root { diff --git a/crates/asset_model/src/model_skel.rs b/crates/asset_model/src/model_skel.rs index eec2de9..5177876 100644 --- a/crates/asset_model/src/model_skel.rs +++ b/crates/asset_model/src/model_skel.rs @@ -95,6 +95,10 @@ pub struct ModelSkel { pub movement_brushes: Vec, + /// The gun bone a T6 attachment copy hangs from (`j_barrel` for a + /// silencer); `None` for the gun's root. + pub mount_tag: Option, + pub lod: Option, pub lod_smc: Option<[[u8; 4]; 4]>, @@ -542,6 +546,8 @@ fn capture_model_skel_iw4( coll_lod: geometry.coll_lod, coll_surfs: capture_coll_surfs(stream, geometry), movement_brushes: Vec::new(), + + mount_tag: None, lod: Some(crate::ModelLodSelector::Iw4 { lod_start: geometry.lod_start, num_lods: geometry.num_lods, @@ -1092,6 +1098,8 @@ fn capture_model_skel_t5( coll_lod: geometry.coll_lod, coll_surfs, movement_brushes: capture_movement_brushes_t5(stream, geometry)?, + + mount_tag: None, lod: Some(crate::ModelLodSelector::T5 { num_lods: geometry.num_lods, lod_dist: geometry.lod_dist, @@ -1722,6 +1730,8 @@ fn capture_model_skel_iw5( coll_surfs: Vec::new(), movement_brushes: Vec::new(), + mount_tag: None, + lod: None, lod_smc: None, lod_part_bits: None, diff --git a/crates/asset_model/src/model_skel_t6.rs b/crates/asset_model/src/model_skel_t6.rs index 898c885..7c2dbf1 100644 --- a/crates/asset_model/src/model_skel_t6.rs +++ b/crates/asset_model/src/model_skel_t6.rs @@ -296,6 +296,7 @@ pub fn capture_model_skel_t6( coll_surfs, // Weapons and arms: nothing the player collides with. movement_brushes: Vec::new(), + mount_tag: None, lod: Some(crate::ModelLodSelector::T5 { num_lods: model.h_u16(off::NUM_LODS) as i16, lod_dist, diff --git a/crates/assets/src/lane/t6.rs b/crates/assets/src/lane/t6.rs index 582d89c..9c49092 100644 --- a/crates/assets/src/lane/t6.rs +++ b/crates/assets/src/lane/t6.rs @@ -149,6 +149,7 @@ fn capture_xanims( asset_game::t6_stand_in_for(name).is_some() || name == asset_game::T6_MELEE_WEAPON }) { wanted.extend(asset_game::t6_weapon_xanim_names(weapon)); + wanted.extend(asset_game::t6_attachment_xanim_names(weapon)); } } let (total, mut captured) = (wanted.len(), 0usize); @@ -204,6 +205,7 @@ fn capture_sounds( .is_some() { names.extend(asset_game::t6_weapon_sound_names(weapon)); + names.extend(asset_game::t6_attachment_sound_names(weapon)); } } names.extend(content.note_sounds.iter().cloned()); @@ -931,6 +933,9 @@ fn capture_content( // Where each attached model (a magazine, a sniper's scope) rests on its // gun's root bone. let mut placements: BTreeMap = BTreeMap::new(); + // An attachment model's copy placed for one weapon: the model it + // copies and the gun bone it hangs from. + let mut copies: BTreeMap)> = BTreeMap::new(); for asset in &load.assets { let Some(weapon) = WeaponView::new(load, asset) else { continue; @@ -950,6 +955,15 @@ fn capture_content( .or_insert((view, false, stand_in)); } } + for unique in weapon.attachment_uniques() { + for view in [true, false] { + for placed in asset_game::t6_attachment_models(unique, view) { + placements.insert(placed.copy.clone(), (placed.offset, placed.angles)); + wanted.entry(placed.copy.clone()).or_insert((view, false, stand_in)); + copies.insert(placed.copy, (placed.model, placed.tag)); + } + } + } for view in [true, false] { for slot in 0..fastfile_t6::weapon::variant::ATTACH_MODEL_COUNT { if let Some((name, offset, angles)) = weapon.attached_model(slot, view) { @@ -973,7 +987,9 @@ fn capture_content( let mut textures = DecodedTextures::new(); let mut native_textures = DecodedTextures::new(); for (name, (view, hands, stand_in)) in wanted { - let Some(&(load, asset)) = models.get(name.as_str()) else { + let copy = copies.get(&name); + let source = copy.map_or(name.as_str(), |(model, _)| model.as_str()); + let Some(&(load, asset)) = models.get(source) else { content .report .push(format!("t6 content: {name}: in no zone read")); @@ -994,6 +1010,10 @@ fn capture_content( { pose.root_rest = Some(attachment_rest(offset, angles)); } + if let Some((_, tag)) = copy { + skel.name = name.clone(); + skel.mount_tag = tag.clone(); + } let mut surface_materials = Vec::with_capacity(model.surface_count()); for surface in 0..model.surface_count() { let material = model diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index 7781bce..93821bc 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -739,6 +739,11 @@ async fn prepare_common(key: CommonKey) -> Arc { weapons.set_family_tables(cac_tables.clone()); let iw5_prepared = weapons.prepare_iw5_configurations(); + let t6_prepared = weapons.prepare_t6_configurations(); + common_report.push(format!( + "T6 configurations: prepared={} refused={}", + t6_prepared.prepared, t6_prepared.refused + )); weapons.resolve_fpv_mesh_edges(&fpv_meshes); weapons.resolve_fpv_hands(&fpv_meshes, &asset_model::BodyMeshCatalog::default()); weapons.resolve_world_model_edges(&world_weapons); diff --git a/crates/assets/src/session_load/common_walks.rs b/crates/assets/src/session_load/common_walks.rs index a34921d..8a27ef8 100644 --- a/crates/assets/src/session_load/common_walks.rs +++ b/crates/assets/src/session_load/common_walks.rs @@ -884,7 +884,7 @@ fn t6_class_tables( if asset_game::is_stats_table_name(&table.name) { Some(table) } else if table.name.eq_ignore_ascii_case("mp/attachmentTable.csv") { - Some(t6_dual_wield_attachment(table)) + Some(table) } else { None } @@ -898,29 +898,6 @@ fn t6_class_tables( tables } -/// The T6 attachment table cut to its header and the dual-wield row. T6 -/// builds its other attachments onto a gun at runtime, which no weapon row -/// here carries; dual wield is a weapon pair of its own (`X_dw_mp` and -/// `X_lh_mp`), so it is the one attachment a class can hold. -fn t6_dual_wield_attachment( - table: asset_game::CapturedStringTable, -) -> asset_game::CapturedStringTable { - let keep: Vec = (0..table.rows as i32) - .filter(|&row| row == 0 || table.cell(row, 4).eq_ignore_ascii_case("dw")) - .collect(); - let cells = keep - .iter() - .flat_map(|&row| (0..table.columns as i32).map(move |col| (row, col))) - .map(|(row, col)| table.cell(row, col).to_owned()) - .collect(); - asset_game::CapturedStringTable { - name: table.name.clone(), - columns: table.columns, - rows: keep.len(), - cells, - } -} - pub(super) fn walk_t6_weapon_bundle( progress: &LoadProgress, ) -> ( diff --git a/crates/fastfile_t6/src/walk.rs b/crates/fastfile_t6/src/walk.rs index 2d24dd3..41a0436 100644 --- a/crates/fastfile_t6/src/walk.rs +++ b/crates/fastfile_t6/src/walk.rs @@ -187,6 +187,20 @@ pub struct LoadedAsset { /// inline as another asset's dependency. pub list_index: Option, pub header: Vec, + /// The assets the header's own pointer fields named when it was + /// loaded, by field offset. Read these rather than [`ZoneLoad::asset_at`] + /// on the header's address, which later loads may reuse. + pub fields: Vec<(u32, usize)>, +} + +impl LoadedAsset { + /// Index (into [`ZoneLoad::assets`]) of the asset field `offset` names. + pub fn field(&self, offset: u32) -> Option { + self.fields + .iter() + .find(|(at, _)| *at == offset) + .map(|&(_, index)| index) + } } pub struct ZoneLoad { @@ -290,6 +304,11 @@ struct Walker<'a> { temp_saved: Vec, assets: Vec, asset_slots: BTreeMap, + /// Every slot bound, in order: an asset's own fields are the ones + /// bound while it loaded. + slot_log: Vec<(Ptr, usize)>, + /// Each asset by where its header sits, as of now (memory is reused). + addresses: BTreeMap, script_strings: Option<(Ptr, usize)>, } @@ -596,7 +615,7 @@ impl<'a> Walker<'a> { let aliased = self.mem.u32_at(target)?; self.mem.write_u32(slot, aliased)?; if let Some(&i) = self.asset_slots.get(&target) { - self.asset_slots.insert(slot, i); + self.bind_slot(slot, i); } } // Otherwise the offset already is the pointer, in zone encoding. @@ -761,9 +780,15 @@ impl<'a> Walker<'a> { Ok(()) } + fn bind_slot(&mut self, slot: Ptr, index: usize) { + self.asset_slots.insert(slot, index); + self.slot_log.push((slot, index)); + } + /// `Loader_X::Load(&slot)` → `LoadPtr_X(false)`. fn load_asset(&mut self, asset: usize, slot: Ptr, list_index: Option) -> Result<()> { let schema: &'a AssetSchema = &self.schema.assets[asset]; + let log_start = self.slot_log.len(); let in_temp = schema.root_in_temp; if in_temp { self.push(XFILE_BLOCK_TEMP); @@ -790,24 +815,38 @@ impl<'a> Walker<'a> { let ty = asset_type_for(&schema.name).ok_or(WalkError::Schema)?; let header = self.mem.bytes(at, root.size as usize)?.to_vec(); + let end = at.at(root.size); + let mut fields: Vec<(u32, usize)> = Vec::new(); + for &(field, index) in &self.slot_log[log_start..] { + if (at..end).contains(&field) { + let offset = field.offset - at.offset; + fields.retain(|(o, _)| *o != offset); + fields.push((offset, index)); + } + } let index = self.assets.len(); self.assets.push(LoadedAsset { ty, list_index, header, + fields, }); - self.asset_slots.insert(slot, index); + self.bind_slot(slot, index); + self.addresses.insert(at, index); if let Some(ins) = insert_slot { self.mem.write_u32(ins, at.encode())?; - self.asset_slots.insert(ins, index); + self.bind_slot(ins, index); } } else if in_temp { let target = Ptr::decode(raw).ok_or(WalkError::BadPointer { raw })?; let aliased = self.mem.u32_at(target)?; self.mem.write_u32(slot, aliased)?; if let Some(&i) = self.asset_slots.get(&target) { - self.asset_slots.insert(slot, i); + self.bind_slot(slot, i); } + } else if let Some(&i) = Ptr::decode(raw).and_then(|at| self.addresses.get(&at)) { + // A pointer to an asset loaded earlier, left as it is. + self.bind_slot(slot, i); } } if in_temp { @@ -848,6 +887,8 @@ pub fn load_zone( temp_saved: Vec::new(), assets: Vec::new(), asset_slots: BTreeMap::new(), + slot_log: Vec::new(), + addresses: BTreeMap::new(), script_strings: None, }; let result = walk_list(&mut w, &mut on_asset); diff --git a/crates/fastfile_t6/src/weapon.rs b/crates/fastfile_t6/src/weapon.rs index a6607dd..dbeba6b 100644 --- a/crates/fastfile_t6/src/weapon.rs +++ b/crates/fastfile_t6/src/weapon.rs @@ -26,6 +26,11 @@ pub mod variant { pub const ATTACH_VIEW_MODEL_ROTATIONS: u32 = 248; pub const ATTACH_WORLD_MODEL_ROTATIONS: u32 = 344; pub const ATTACH_MODEL_COUNT: u32 = 8; + /// `WeaponAttachmentUnique** attachmentUniques`: what each attachment + /// (and each authored pair, see [`super::unique::COMBINED_MASK`]) does + /// to this weapon, [`ATTACHMENT_UNIQUE_COUNT`] slots. + pub const ATTACHMENT_UNIQUES: u32 = 28; + pub const ATTACHMENT_UNIQUE_COUNT: u32 = 95; pub const CLIP_SIZE: u32 = 476; pub const RELOAD_TIME: u32 = 480; pub const RELOAD_EMPTY_TIME: u32 = 484; @@ -261,6 +266,177 @@ pub mod weap_anim { pub const ADS_DOWN: usize = 86; } +/// `WeaponAttachmentUnique` (424 bytes): one attachment as one weapon +/// carries it. +pub mod unique { + pub const NAME: u32 = 0; + /// `eAttachment`: the attachment table's index (`acog` 1, `gl` 11, …). + pub const TYPE: u32 = 4; + /// Non-zero for an authored pair: `1 << type` of both attachments. + pub const COMBINED_MASK: u32 = 16; + pub const ALT_WEAPON_NAME: u32 = 20; + /// `unsigned short* hideTags`: 32 script strings. + pub const HIDE_TAGS: u32 = 36; + pub const HIDE_TAG_COUNT: u32 = 32; + pub const VIEW_MODEL: u32 = 40; + pub const VIEW_MODEL_ADDITIONAL: u32 = 44; + pub const WORLD_MODEL: u32 = 52; + pub const WORLD_MODEL_ADDITIONAL: u32 = 56; + /// The gun bone the models hang from; empty for the gun's root. + pub const VIEW_MODEL_TAG: u32 = 60; + pub const WORLD_MODEL_TAG: u32 = 64; + pub const VIEW_MODEL_OFFSETS: u32 = 68; + pub const WORLD_MODEL_OFFSETS: u32 = 80; + pub const VIEW_MODEL_ROTATIONS: u32 = 92; + pub const WORLD_MODEL_ROTATIONS: u32 = 104; + pub const VIEW_MODEL_ADD_OFFSETS: u32 = 116; + pub const WORLD_MODEL_ADD_OFFSETS: u32 = 128; + pub const VIEW_MODEL_ADD_ROTATIONS: u32 = 140; + pub const WORLD_MODEL_ADD_ROTATIONS: u32 = 152; + /// The weapon's own attached optic (or its magazine) is removed. + pub const DISABLE_BASE_ATTACHMENT: u32 = 168; + pub const DISABLE_BASE_CLIP: u32 = 169; + /// `const char** szXAnims`: the weapon's clips with this attachment, by + /// [`super::weap_anim`]; an empty name keeps the weapon's own. + pub const XANIMS: u32 = 232; + pub const FIRE_SOUND: u32 = 256; + pub const FIRE_SOUND_PLAYER: u32 = 260; +} + +/// An attached model of a [`AttachmentUniqueView`]: its name, the gun bone +/// it hangs from (`None` for the root) and its offset and `(pitch, yaw, +/// roll)` degrees there. +pub type UniqueModel<'z> = (&'z str, Option<&'z str>, [f32; 3], [f32; 3]); + +/// A `WeaponAttachmentUnique` of a finished zone load. +#[derive(Clone, Copy)] +pub struct AttachmentUniqueView<'z> { + load: &'z ZoneLoad, + asset: &'z LoadedAsset, +} + +impl<'z> AttachmentUniqueView<'z> { + fn u32_at(&self, off: u32) -> u32 { + let o = off as usize; + self.asset + .header + .get(o..o + 4) + .map_or(0, |b| u32::from_le_bytes(b.try_into().unwrap())) + } + + fn vec3(&self, off: u32) -> [f32; 3] { + core::array::from_fn(|i| f32::from_bits(self.u32_at(off + 4 * i as u32))) + } + + fn str_at(&self, off: u32) -> Option<&'z str> { + let p = crate::walk::decode_ptr(self.u32_at(off))?; + self.load + .blocks + .cstr(p) + .ok() + .and_then(|b| core::str::from_utf8(b).ok()) + .filter(|name| !name.is_empty()) + } + + pub fn name(&self) -> Option<&'z str> { + self.str_at(unique::NAME) + } + + pub fn attachment_type(&self) -> u32 { + self.u32_at(unique::TYPE) + } + + pub fn combined_mask(&self) -> u32 { + self.u32_at(unique::COMBINED_MASK) + } + + pub fn alt_weapon(&self) -> Option<&'z str> { + self.str_at(unique::ALT_WEAPON_NAME) + } + + pub fn flag(&self, off: u32) -> bool { + self.asset.header.get(off as usize).is_some_and(|&b| b != 0) + } + + pub fn sound(&self, off: u32) -> Option<&'z str> { + self.str_at(off) + } + + /// The asset a model field named when this was loaded. + fn model_name(&self, off: u32) -> Option<&'z str> { + let model = &self.load.assets[self.asset.field(off)?]; + let p = crate::walk::decode_ptr(u32::from_le_bytes(model.header.get(0..4)?.try_into().ok()?))?; + self.load + .blocks + .cstr(p) + .ok() + .and_then(|b| core::str::from_utf8(b).ok()) + } + + /// The models this attachment hangs on the first-person (`view`) or + /// world gun: the main one, then the additional one (a mount). + pub fn models(&self, view: bool) -> [Option>; 2] { + use unique as u; + let (model, add, tag, offsets, rotations, add_offsets, add_rotations) = if view { + ( + u::VIEW_MODEL, + u::VIEW_MODEL_ADDITIONAL, + u::VIEW_MODEL_TAG, + u::VIEW_MODEL_OFFSETS, + u::VIEW_MODEL_ROTATIONS, + u::VIEW_MODEL_ADD_OFFSETS, + u::VIEW_MODEL_ADD_ROTATIONS, + ) + } else { + ( + u::WORLD_MODEL, + u::WORLD_MODEL_ADDITIONAL, + u::WORLD_MODEL_TAG, + u::WORLD_MODEL_OFFSETS, + u::WORLD_MODEL_ROTATIONS, + u::WORLD_MODEL_ADD_OFFSETS, + u::WORLD_MODEL_ADD_ROTATIONS, + ) + }; + let tag = self.str_at(tag); + [ + (model, offsets, rotations), + (add, add_offsets, add_rotations), + ] + .map(|(model, offsets, rotations)| { + Some(( + self.model_name(model)?, + tag, + self.vec3(offsets), + self.vec3(rotations), + )) + }) + } + + /// The clip in `slot` ([`weap_anim`]), when this attachment names one. + pub fn xanim(&self, slot: u32) -> Option<&'z str> { + let arr = crate::walk::decode_ptr(self.u32_at(unique::XANIMS))?; + let p = self.load.blocks.ptr_at(arr.at(4 * slot)).ok()??; + self.load + .blocks + .cstr(p) + .ok() + .and_then(|b| core::str::from_utf8(b).ok()) + .filter(|name| !name.is_empty()) + } + + /// The gun bones this attachment hides (iron sights under an optic). + pub fn hide_tags(&self) -> impl Iterator + 'z { + let load = self.load; + let arr = crate::walk::decode_ptr(self.u32_at(unique::HIDE_TAGS)); + (0..unique::HIDE_TAG_COUNT).filter_map(move |i| { + let b = load.blocks.bytes(arr?.at(2 * i), 2).ok()?; + let id = u16::from_le_bytes([b[0], b[1]]); + (id != 0).then(|| load.script_string(id)).flatten() + }) + } +} + /// `weapType_t`. pub mod weap_type { pub const BULLET: i32 = 0; @@ -342,6 +518,18 @@ impl<'z> WeaponView<'z> { crate::walk::decode_ptr(self.variant_u32(off)) } + /// The variant's attachment uniques: one per attachment it takes, and + /// one per authored pair. + pub fn attachment_uniques(&self) -> impl Iterator> + 'z { + let load = self.load; + let arr = self.variant_ptr(variant::ATTACHMENT_UNIQUES); + (0..variant::ATTACHMENT_UNIQUE_COUNT).filter_map(move |i| { + let asset = load.asset_at(arr?.at(4 * i))?; + (asset.ty == crate::AssetType::AttachmentUnique) + .then_some(AttachmentUniqueView { load, asset }) + }) + } + pub fn has_def(&self) -> bool { self.def.is_some() } diff --git a/crates/render_anim/src/anim/fpv_rig.rs b/crates/render_anim/src/anim/fpv_rig.rs index 6a7f21a..c42d455 100644 --- a/crates/render_anim/src/anim/fpv_rig.rs +++ b/crates/render_anim/src/anim/fpv_rig.rs @@ -601,7 +601,18 @@ impl PreparedFpvRig { .collect(); let assembly = &self.composition.assembly; let world = assembly.dobj.pose(&instances, &self.parts, Mat4::IDENTITY); - let skin = assembly.dobj.skin_matrices(&world); + let mut skin = assembly.dobj.skin_matrices(&world); + // A hidden T6 gun bone takes its vertices to a point. + for &bone in &assembly.collapsed_bones { + if let (Some(skin), Some(world)) = (skin.get_mut(bone), world.get(bone)) { + *skin = Mat4::from_cols( + bevy::math::Vec4::ZERO, + bevy::math::Vec4::ZERO, + bevy::math::Vec4::ZERO, + world.w_axis, + ); + } + } let eye_from_world = tag_view_to_bevy_camera() * world[assembly.view_bone].inverse(); let lens = assembly .camera_bone