diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 3685fabf2..12ef7e79a 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -974,9 +974,13 @@ fn setup_entities( match load_item_display_catalog(&mut chain) { Ok(catalog) => { info!("item display catalog: {} entries", catalog.len()); + let races = benilla_formats::load_chr_races(&mut chain) + .map_err(|e| warn!("ChrRaces.dbc unavailable, no helm shows: {e:#}")) + .unwrap_or_default(); commands.insert_resource(ItemDisplays { catalog, models: HashMap::new(), + races, }); } Err(e) => warn!("item displays unavailable, units hold nothing: {e:#}"), diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs index 3f96d57e4..f86b8429b 100644 --- a/crates/benilla-app/src/entities/equipment/mod.rs +++ b/crates/benilla-app/src/entities/equipment/mod.rs @@ -58,6 +58,8 @@ pub(crate) struct ItemDisplays { pub(crate) catalog: ItemDisplayCatalog, /// Keyed by model kind too: a helm display has a file per race and sex, a shoulder a pair. pub(super) models: HashMap<(u32, ItemModelKind), DisplayModel>, + /// `ChrRaces.dbc`, whose client prefix names a helm's model per race (`0x479a21`). + pub(crate) races: benilla_formats::ChrRaces, } #[cfg(test)] @@ -66,6 +68,7 @@ impl ItemDisplays { ItemDisplays { catalog, models: HashMap::new(), + races: benilla_formats::ChrRaces::default(), } } } @@ -372,6 +375,21 @@ pub(crate) const ATTACH_SLOT_NAMES: [&str; ATTACH_SLOTS] = [ /// (`0x47a200`) and the ranged and ammo builder pass the display's visual. const NO_GLOW: i32 = 0; +/// A helm display's model file for a race and sex: `_.m2`, the prefix the +/// `ChrRaces` row's client prefix (`+0x18`, `0x479a21`, formatted at `0x479a41`). A race with no +/// row has no model, and `0x4799a0` attaches no helm (`0x4799cc`, `0x4799d8`, `0x4799e9`). +fn helm_model_name( + model: &str, + races: &benilla_formats::ChrRaces, + race: u8, + sex: u8, +) -> Option { + let prefix = races.client_prefix(u32::from(race))?; + let letter = if sex == 1 { 'F' } else { 'M' }; + let stem = model.strip_suffix(".m2").unwrap_or(model); + Some(format!("{stem}_{prefix}{letter}.m2")) +} + /// Ensure `display` has a [`DisplayModel`] for `kind`; its texture is the BLP the display row /// names, never derived from the model name. pub(in crate::entities) fn ensure_item_model( @@ -407,12 +425,13 @@ pub(in crate::entities) fn ensure_item_model( Some(d) if d.model[col].is_some() => { let mut model = d.model[col].clone().unwrap(); if let ItemModelKind::Helm { race, sex } = kind { - // Race 1-8 is Hu Or Dw Ni Sc Ta Gn Tr, sex M or F. - const RACE_PREFIX: [&str; 8] = ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr"]; - let prefix = RACE_PREFIX[(race.clamp(1, 8) - 1) as usize]; - let letter = if sex == 1 { 'F' } else { 'M' }; - let stem = model.strip_suffix(".m2").unwrap_or(&model).to_string(); - model = format!("{stem}_{prefix}{letter}.m2"); + // `0x4799a0` names the model by the `ChrRaces` row's client prefix, and a race + // with no row attaches no helm. + let Some(named) = helm_model_name(&model, &held.races, race, sex) else { + held.models.insert((display, kind), super::empty_display()); + return; + }; + model = named; } let disp_id = display; debug!( @@ -431,3 +450,49 @@ pub(in crate::entities) fn ensure_item_model( }; held.models.insert((display, kind), dm); } + +#[cfg(test)] +mod helm_model_tests { + use super::helm_model_name; + use benilla_formats::ChrRaces; + + fn races() -> ChrRaces { + ChrRaces::from_rows([ + (1, "Human", "Human"), + (8, "Troll", "Troll"), + (9, "Goblin", "Goblin"), + ]) + .with_client_prefix(1, "Hu") + .with_client_prefix(8, "Tr") + .with_client_prefix(9, "Gb") + } + + /// The prefix is the race row's, so race 9 gets its own model and not the Troll one a clamp to + /// eight gave it; a race with no row, 0 included, has none (`0x4799cc`-`0x4799e9`). + #[test] + fn a_helm_takes_its_races_prefix_and_a_race_without_a_row_has_none() { + let races = races(); + let name = |race, sex| helm_model_name("Helm_Plate.m2", &races, race, sex); + assert_eq!(name(1, 0).as_deref(), Some("Helm_Plate_HuM.m2")); + assert_eq!(name(8, 1).as_deref(), Some("Helm_Plate_TrF.m2")); + assert_eq!(name(9, 0).as_deref(), Some("Helm_Plate_GbM.m2")); + assert_eq!(name(10, 0), None); + assert_eq!(name(0, 0), None); + } + + /// The shipped `ChrRaces.dbc` through the same read: the eight playable prefixes in race + /// order, `Gb` for the goblin, nothing past the nine rows. + #[test] + fn the_shipped_races_name_their_helm_models() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let races = benilla_formats::load_chr_races(&mut chain).expect("ChrRaces.dbc"); + let prefixes: Vec<_> = (1..=9).map(|r| races.client_prefix(r)).collect(); + assert_eq!( + prefixes, + ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr", "Gb"].map(Some) + ); + assert_eq!(races.client_prefix(0), None); + assert_eq!(races.client_prefix(10), None); + } +} diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index acd6e91df..fcf1cd7fe 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -648,16 +648,38 @@ pub(crate) enum ItemUseRoute { /// `0x5d926d`) locally and sends nothing. Only the worn hand the disarm hides (slot 15 or 16) /// is refused, and the rung sits above the bind confirm (`0x5d91d6`) and the cast tail. CantUseDisarmed, + /// Rung 16: a shapeshifted player whose form row bars the item is refused + /// `ERR_NO_ITEMS_WHILE_SHAPESHIFTED` (`0x15e`, `0x5d914d`) and nothing is sent. It sits below + /// the disarmed refusal and above the toggle scan, the bind ask and the cast tail. + NoItemsShapeshifted, +} + +/// `0x5d9105`-`0x5d914d`: whether the player's form refuses an item. A form is shapeshifted +/// (`IsShapeShifted`, `0x60e9f0`) when the byte is nonzero and its `SpellShapeshiftForm` row is +/// not a stance (`flags1` bit 0); the row (`+0x2c`) then passes the item with bit `0x8`, or with +/// bit `0x40` for an equippable one (`0x5d9ff0`, a nonzero inventory type). A form with no row +/// passes (`0x5d9134`). +pub(crate) fn form_refuses_item( + forms: &std::collections::HashMap, + form: u8, + equippable: bool, +) -> bool { + form != 0 + && forms + .get(&u32::from(form)) + .is_some_and(|row| !row.is_stance() && !row.allows_items(equippable)) } /// [`ItemUseRoute`]'s decision, in the reference's order: quest offer (`0x5d8dcc`), charter -/// (`0x5d8f95`), disarmed refusal, toggle scan (`0x5d9157`), cast tail (`0x5d9249`). The toggle -/// predicate and the disarmed hand are caster state, passed in; a `None` guid cannot address a +/// (`0x5d8f95`), disarmed refusal, form refusal (`0x5d9105`), toggle scan (`0x5d9157`), cast tail +/// (`0x5d9249`). The toggle predicate, the disarmed hand and the form refusal are caster state, +/// passed in; a `None` guid cannot address a /// questgiver, so it falls through to the ordinary path. pub(crate) fn item_use_route( it: ItemUse, aura_cancels: impl Fn(u32) -> bool, disarmed_hand: Option, + form_refuses: bool, ) -> ItemUseRoute { if let Some(npc) = it.guid.filter(|_| it.start_quest != 0) { return ItemUseRoute::QuestOffer { @@ -674,6 +696,9 @@ pub(crate) fn item_use_route( if disarmed_hand.is_some() && it.bag_index == PLAYER_ARRAY && Some(it.slot) == disarmed_hand { return ItemUseRoute::CantUseDisarmed; } + if form_refuses { + return ItemUseRoute::NoItemsShapeshifted; + } match it.use_spell { Some(spell) if aura_cancels(spell) => ItemUseRoute::ToggleCancel(spell), Some(spell) => ItemUseRoute::Cast(spell), @@ -742,7 +767,30 @@ pub(crate) fn send_item_use( let disarmed_hand = ctx.rel.self_store.and_then(|store| { crate::items::disarmed_equipment_slot(store, &ladder.objects, &ladder.items) }); - match item_use_route(it, aura_cancels, disarmed_hand) { + // The form test (`0x5d9105`) reads the template's inventory type (`0x5d9ff0`). + let form_refuses = { + let form = ctx.rel.self_store.map_or(0, |s| s.0.unit_shapeshift_form()); + let equippable = ladder + .items + .template( + it.entry, + it.guid.unwrap_or(0), + crate::query_cache::Ask::Read, + ) + .is_some_and(|t| t.inventory_type != 0); + ladder + .spells + .as_ref() + .is_some_and(|s| form_refuses_item(&s.forms, form, equippable)) + }; + match item_use_route(it, aura_cancels, disarmed_hand, form_refuses) { + ItemUseRoute::NoItemsShapeshifted => { + debug!("ui_items: item use refused — shapeshifted"); + ui_errors.push(crate::ui_action::UiError::key( + "ERR_NO_ITEMS_WHILE_SHAPESHIFTED", + )); + false + } ItemUseRoute::CantUseDisarmed => { debug!( "ui_items: item use refused — that hand is disarmed (slot {})", @@ -1108,12 +1156,12 @@ mod tests { let never = |_: u32| false; assert_eq!( - item_use_route(worn(15), never, Some(15)), + item_use_route(worn(15), never, Some(15), false), ItemUseRoute::CantUseDisarmed ); // A disarmed dual-wielder's other hand is not hidden. assert_eq!( - item_use_route(worn(16), never, Some(15)), + item_use_route(worn(16), never, Some(15), false), ItemUseRoute::Cast(8690) ); // The same item in a bag is not worn, so it is never this refusal. @@ -1124,18 +1172,19 @@ mod tests { ..worn(15) }, never, - Some(15) + Some(15), + false ), ItemUseRoute::Cast(8690) ); // Flag down: nothing is hidden. assert_eq!( - item_use_route(worn(15), never, None), + item_use_route(worn(15), never, None, false), ItemUseRoute::Cast(8690) ); // The off hand hidden: the ladder's empty or non-weapon main-hand rung. assert_eq!( - item_use_route(worn(16), never, Some(16)), + item_use_route(worn(16), never, Some(16), false), ItemUseRoute::CantUseDisarmed ); } @@ -1158,25 +1207,25 @@ mod tests { }; let never = |_| false; assert_eq!( - item_use_route(it(Some(letter), 373, None), never, None), + item_use_route(it(Some(letter), 373, None), never, None, false), ItemUseRoute::QuestOffer { npc: letter, quest: 373 } ); assert_eq!( - item_use_route(it(Some(letter), 0, Some(8690)), never, None), + item_use_route(it(Some(letter), 0, Some(8690)), never, None, false), ItemUseRoute::Cast(8690), "a hearthstone takes the cast tail" ); assert_eq!( - item_use_route(it(Some(letter), 0, None), never, None), + item_use_route(it(Some(letter), 0, None), never, None, false), ItemUseRoute::Nothing, "no ON_USE block — the ref sends nothing" ); // No resolved instance: nothing to address, so the ordinary path runs. assert_eq!( - item_use_route(it(None, 373, Some(8690)), never, None), + item_use_route(it(None, 373, Some(8690)), never, None, false), ItemUseRoute::Cast(8690) ); } @@ -1199,22 +1248,22 @@ mod tests { }; let never = |_| false; assert_eq!( - item_use_route(it(Some(charter), true, None), never, None), + item_use_route(it(Some(charter), true, None), never, None, false), ItemUseRoute::ShowPetition { item: charter }, "the charter opens its petition window" ); // Without the flag, the same item is the reference's silent no-op. assert_eq!( - item_use_route(it(Some(charter), false, None), never, None), + item_use_route(it(Some(charter), false, None), never, None, false), ItemUseRoute::Nothing ); // No resolved instance: it falls through, as the quest fork does. assert_eq!( - item_use_route(it(None, true, None), never, None), + item_use_route(it(None, true, None), never, None, false), ItemUseRoute::Nothing ); assert_eq!( - item_use_route(it(None, true, Some(8690)), never, None), + item_use_route(it(None, true, Some(8690)), never, None, false), ItemUseRoute::Cast(8690) ); } @@ -1237,18 +1286,18 @@ mod tests { }; let mounted = |spell: u32| spell == SUMMON_HORSE; assert_eq!( - item_use_route(it(0, Some(SUMMON_HORSE)), mounted, None), + item_use_route(it(0, Some(SUMMON_HORSE)), mounted, None, false), ItemUseRoute::ToggleCancel(SUMMON_HORSE), "mounted: the click dismounts — CMSG_CANCEL_AURA, no CMSG_USE_ITEM", ); assert_eq!( - item_use_route(it(0, Some(SUMMON_HORSE)), |_| false, None), + item_use_route(it(0, Some(SUMMON_HORSE)), |_| false, None, false), ItemUseRoute::Cast(SUMMON_HORSE), "not mounted: the very same click casts", ); // Order: the quest offer forks at `0x5d8dcc`, above the toggle scan at `0x5d9157`. assert_eq!( - item_use_route(it(373, Some(SUMMON_HORSE)), mounted, None), + item_use_route(it(373, Some(SUMMON_HORSE)), mounted, None, false), ItemUseRoute::QuestOffer { npc: 0x4000_0000_0000_0BAD, quest: 373 @@ -1256,7 +1305,7 @@ mod tests { ); // An item with no ON_USE block has nothing to cancel, whatever the predicate says. assert_eq!( - item_use_route(it(0, None), |_| true, None), + item_use_route(it(0, None), |_| true, None, false), ItemUseRoute::Nothing ); } @@ -1371,6 +1420,93 @@ mod tests { assert_eq!(wire_pos(EQUIPMENT_BAG, 63), None, "the doll-space gap"); assert_eq!(wire_pos(EQUIPMENT_BAG, 70), None, "past the bank bags"); } + /// The form refusal sits below the quest and charter arms and the disarmed refusal, and above + /// the toggle scan and the cast (`0x5d9105`). + #[test] + fn the_form_refusal_ranks_between_the_disarm_and_the_cast() { + let it = ItemUse { + entry: 7, + guid: Some(1), + start_quest: 0, + bag_index: 1, + slot: 0, + spell_index: 0, + use_spell: Some(8690), + on_object: None, + is_charter: false, + }; + let toggling = |_: u32| true; + assert_eq!( + item_use_route(it, toggling, None, true), + ItemUseRoute::NoItemsShapeshifted + ); + assert_eq!( + item_use_route( + ItemUse { + start_quest: 373, + ..it + }, + toggling, + None, + true + ), + ItemUseRoute::QuestOffer { npc: 1, quest: 373 } + ); + assert_eq!( + item_use_route( + ItemUse { + is_charter: true, + ..it + }, + toggling, + None, + true + ), + ItemUseRoute::ShowPetition { item: 1 } + ); + let worn = ItemUse { + bag_index: super::PLAYER_ARRAY, + slot: 15, + ..it + }; + assert_eq!( + item_use_route(worn, toggling, Some(15), true), + ItemUseRoute::CantUseDisarmed + ); + assert_eq!( + item_use_route(it, toggling, None, false), + ItemUseRoute::ToggleCancel(8690) + ); + } + + /// The stock forms through the gate (`0x5d9136`-`0x5d914d`), by name. + #[test] + fn the_shipped_forms_refuse_items_by_their_flags() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let forms = + benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"); + let refuses = |form: u8, equippable| super::form_refuses_item(&forms, form, equippable); + // Cat, Travel, Aquatic, Bear, Dire Bear (flags 0x40 set) and Ghost Wolf: an equippable + // item passes, any other is refused. + for form in [1, 3, 4, 5, 8, 16] { + assert!(refuses(form, false), "form {form}, a potion"); + assert!(!refuses(form, true), "form {form}, a trinket"); + } + // Tree of Life (flags 0x10) and the unused rows, flags 0: every item is refused. + for form in [2, 6, 7, 9, 10, 11, 12, 13, 14, 15, 20, 29, 32] { + assert!(refuses(form, false) && refuses(form, true), "form {form}"); + } + // The stances (flags bit 0, `IsShapeShifted` false): Battle, Defensive, Berserker, + // Shadowform, Stealth and the rest pass everything. + for form in [17, 18, 19, 28, 30, 31] { + assert!(!refuses(form, false) && !refuses(form, true), "form {form}"); + } + // No stock form is a shapeshift with flag 0x8, which would pass every item. + assert!(forms.values().all(|f| f.is_stance() || f.flags & 0x8 == 0)); + assert!(!super::form_refuses_item(&forms, 0, false), "no form"); + assert!(!super::form_refuses_item(&forms, 200, false), "no row"); + } } /// [`find_item`]'s order, which is the reference walk's. diff --git a/crates/benilla-formats/src/chr_races.rs b/crates/benilla-formats/src/chr_races.rs index 0c2a1c1d0..6cce3ecb3 100644 --- a/crates/benilla-formats/src/chr_races.rs +++ b/crates/benilla-formats/src/chr_races.rs @@ -20,6 +20,10 @@ const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; /// The shipped copy's column count, which `benilla-dbc` enforces as the reference loader does. const CHR_RACES_FIELDS: usize = 29; +/// Field 6, the client prefix (`+0x18`), the `%s` of a helm model's `_.m2` +/// (`0x479a21`). +const PREFIX_FIELD: usize = 0x18 / 4; + /// Field 15, the file string (`+0x3c`). const FILE_FIELD: usize = 0x3c / 4; @@ -34,6 +38,8 @@ const NAME_FIELD: usize = 0x44 / 4; struct Row { name: String, file: String, + /// The client prefix (`+0x18`), `Hu`, `Or`, ..., `Gb`. + prefix: String, /// `CinematicSequenceID` (`+0x40`). intro: u32, } @@ -49,8 +55,16 @@ impl ChrRaces { rows.into_iter() .map(|(id, name, file)| { let (name, file) = (name.to_string(), file.to_string()); - let intro = 0; - (id, Row { name, file, intro }) + let (intro, prefix) = (0, String::new()); + ( + id, + Row { + name, + file, + prefix, + intro, + }, + ) }) .collect(), ) @@ -75,6 +89,18 @@ impl ChrRaces { self.0.get(&race).map(|r| r.intro) } + /// The race row's client prefix (`+0x18`, `0x479a21`) that a helm model's name is built with; + /// a race with no row has none, and `0x4799a0` attaches no helm for it (`0x4799cc`-`0x4799e9`). + pub fn client_prefix(&self, race: u32) -> Option<&str> { + self.0.get(&race).map(|r| r.prefix.as_str()) + } + + /// A table for tests of what reads the client prefix: `race` holds `prefix`. + pub fn with_client_prefix(mut self, race: u32, prefix: &str) -> Self { + self.0.entry(race).or_default().prefix = prefix.to_string(); + self + } + /// A table for tests of what reads the intro column: `race` holds `sequence`. #[cfg(test)] pub(crate) fn with_intro_sequence(mut self, race: u32, sequence: u32) -> Self { @@ -90,7 +116,8 @@ impl ChrRaces { fn schema() -> Schema { let mut s = Schema::new("ChrRaces"); for i in 0..CHR_RACES_FIELDS { - let ty = if i == FILE_FIELD + let ty = if i == PREFIX_FIELD + || i == FILE_FIELD || (NAME_FIELD..NAME_FIELD + 8).contains(&i) || (26..=28).contains(&i) { @@ -122,6 +149,7 @@ fn table_from_bytes(bytes: &[u8], locale: Locale) -> Result { Row { name: loc_str(&rs, r, NAME_FIELD, locale).unwrap_or_default(), file: str_at(&rs, r, FILE_FIELD).unwrap_or_default(), + prefix: str_at(&rs, r, PREFIX_FIELD).unwrap_or_default(), intro: u32_at(r, INTRO_FIELD).unwrap_or(0), }, ); diff --git a/crates/benilla-formats/src/spells/forms.rs b/crates/benilla-formats/src/spells/forms.rs index 7d6b9ea70..4bd637179 100644 --- a/crates/benilla-formats/src/spells/forms.rs +++ b/crates/benilla-formats/src/spells/forms.rs @@ -44,6 +44,12 @@ impl ShapeshiftForm { self.flags & 0x8 != 0 } + /// Flag bit `0x8` of `flags1` also lets the form use any item: `CGItem::Use` refuses an item + /// in a form without it, unless bit `0x40` and the item is equippable (`0x5d9136`-`0x5d914d`). + pub fn allows_items(&self, equippable: bool) -> bool { + self.flags & 0x8 != 0 || (self.flags & 0x40 != 0 && equippable) + } + /// Clicking the active form's button cancels its aura (`CMSG_CANCEL_AURA`) unless bit `0x2` /// makes it a silent no-op (`CastShapeshiftForm` `0x4b4810`, guard at `0x4b4963`); the /// warrior stances (`0x7`) set it on 5875.