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 <noreply@anthropic.com>
This commit is contained in:
neptune
2026-10-01 16:46:34 -04:00
parent af8ee254c1
commit 0987b48fb7
12 changed files with 708 additions and 48 deletions
+37 -9
View File
@@ -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<usize>,
}
/// 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<FpvMeshIndex>,
pub knife: Option<FpvMeshIndex>,
pub hide_tags: Vec<String>,
pub hide_mode: FpvHideMode,
}
#[derive(Clone, Debug)]
@@ -117,6 +130,7 @@ impl FpvAssembly {
rocket: bool,
knife: Option<FpvMeshIndex>,
hide_tags: &[String],
hide_mode: FpvHideMode,
) -> Result<Self, FpvAssemblyError> {
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,
})
}
+360 -5
View File
@@ -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<String>; 2],
/// Of those, the magazine (attachment slot 6), which a fast-mag
/// attachment replaces.
pub t6_clip_models: [Option<String>; 2],
/// What each T6 attachment the weapon takes does to it.
pub t6_attachments: Vec<T6Attachment>,
pub iw5_reload_overrides: Vec<fastfile_iw5::ReloadOverride>,
pub iw5_anim_overrides: Vec<LeftoverAnimOverride>,
pub iw5_fx_overrides: Vec<Iw5FxOverride>,
@@ -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<String>; 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<String>; 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<String>; 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<String>,
/// First-person and world models, named as their placed copies (see
/// [`t6_attachment_models`]).
pub models: [Vec<String>; 2],
/// Gun bones the attachment hides (iron sights under an optic).
pub hide_tags: Vec<String>,
/// Clips the attachment plays instead of the weapon's, in IW4 slots.
pub xanims: [Option<String>; WEAPON_ANIM_SLOTS],
pub fire_sound: Option<String>,
pub fire_sound_player: Option<String>,
/// 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<String>,
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<T6AttachmentModel> {
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<T6Attachment> {
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<String> {
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<String> {
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<String> {
(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<String>,
attachment_world_models: Vec<String>,
/// See [`CatalogWeapon::t6_clip_models`] and
/// [`CatalogWeapon::t6_attachments`].
t6_clip_models: [Option<String>; 2],
t6_attachments: Vec<T6Attachment>,
iw5_configuration: Option<(u32, Iw5AttachmentSelection)>,
prepared_attachments: Vec<String>,
@@ -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<crate::WeaponSelection>,
}
/// `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<WeaponRow> {
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<String, u32> {
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::<Vec<_>>()
})
.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<Vec<u32>> = 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,
+20 -3
View File
@@ -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(
+8 -1
View File
@@ -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 {
+10
View File
@@ -95,6 +95,10 @@ pub struct ModelSkel {
pub movement_brushes: Vec<xmodel_runtime::ModelMovementBrush>,
/// The gun bone a T6 attachment copy hangs from (`j_barrel` for a
/// silencer); `None` for the gun's root.
pub mount_tag: Option<String>,
pub lod: Option<crate::ModelLodSelector>,
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,
+1
View File
@@ -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,
+21 -1
View File
@@ -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<String, ([f32; 3], [f32; 3])> = 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<String, (String, Option<String>)> = 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
@@ -739,6 +739,11 @@ async fn prepare_common(key: CommonKey) -> Arc<CommonSet> {
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);
+1 -24
View File
@@ -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<i32> = (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,
) -> (
+45 -4
View File
@@ -187,6 +187,20 @@ pub struct LoadedAsset {
/// inline as another asset's dependency.
pub list_index: Option<usize>,
pub header: Vec<u8>,
/// 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<usize> {
self.fields
.iter()
.find(|(at, _)| *at == offset)
.map(|&(_, index)| index)
}
}
pub struct ZoneLoad {
@@ -290,6 +304,11 @@ struct Walker<'a> {
temp_saved: Vec<usize>,
assets: Vec<LoadedAsset>,
asset_slots: BTreeMap<Ptr, usize>,
/// 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<Ptr, usize>,
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<usize>) -> 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);
+188
View File
@@ -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<UniqueModel<'z>>; 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<Item = &'z str> + '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<Item = AttachmentUniqueView<'z>> + '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()
}
+12 -1
View File
@@ -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