diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index bf503e1d..3a48574c 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -1074,6 +1074,9 @@ fn seat_a_session(script: &mut UiScript) { class_file: Some("WARRIOR".into()), sex: 2, is_player: true, + // `UnitCreatureType("player")`: the feed's snapshot carries the race's type, and a + // Human is a Humanoid. + creature_type_name: Some("Humanoid".into()), // `UnitFactionGroup("player")`: every playable race has a side, and AceDB-2.0 // concatenates it into its per-realm key at file scope. faction_group: Some("Alliance".into()), diff --git a/crates/benilla-app/src/creature_type.rs b/crates/benilla-app/src/creature_type.rs index b130b9b2..94ef215c 100644 --- a/crates/benilla-app/src/creature_type.rs +++ b/crates/benilla-app/src/creature_type.rs @@ -10,6 +10,7 @@ use benilla_formats::ShapeshiftForm; use crate::names::NameCache; use crate::net::ObjectStore; +use crate::ui_action::Spells; /// What the resolver reads beside the unit's own fields: the creature template cache and the /// form table. A source that is absent resolves nothing at its stage. @@ -19,7 +20,16 @@ pub(crate) struct CreatureTypeSources<'a> { pub(crate) forms: Option<&'a HashMap>, } -impl CreatureTypeSources<'_> { +impl<'a> CreatureTypeSources<'a> { + /// The sources a system holds as resources: the name cache and, once `Spell.dbc` has loaded, + /// the form table it carries. + pub(crate) fn of_resources(names: &'a NameCache, spells: Option<&'a Spells>) -> Self { + Self { + names: Some(names), + forms: spells.map(|s| &s.forms), + } + } + /// The unit's creature type, 0 for none. The template stage answers whatever the record /// holds, 0 included, and is keyed by `OBJECT_FIELD_ENTRY` as the reference's cache is. pub(crate) fn of(&self, store: &ObjectStore) -> u32 { @@ -101,8 +111,8 @@ mod tests { creature_type, ..Default::default() }; - // Cat form is a Beast; a warrior stance carries type 0. - HashMap::from([(1, row(1)), (17, row(0))]) + // Cat form is a Beast; a warrior stance carries type 0, and Tree of Life -1. + HashMap::from([(1, row(1)), (2, row(-1)), (17, row(0))]) } /// The three stages in the reference's order: form above 0, then the template, then the @@ -118,8 +128,9 @@ mod tests { // A cat-form player is a Beast; unshifted, a Humanoid by race. assert_eq!(sources.of(&store(0, 1, 1)), 1); assert_eq!(sources.of(&store(0, 1, 0)), 7); - // A stance row of type 0 falls through to the race, not to a fixed answer. + // A row of type 0 or -1 falls through to the race, not to a fixed answer. assert_eq!(sources.of(&store(0, 5, 17)), 7); + assert_eq!(sources.of(&store(0, 5, 2)), 7); // A cached creature is its template's type; the form still wins over it. assert_eq!(sources.of(&store(69, 0, 0)), 1); let humanoid = names_with(69, 7); @@ -159,6 +170,68 @@ mod tests { assert_eq!(CreatureTypeSources::default().of(&store(69, 0, 1)), 0); } + /// The shipped `SpellShapeshiftForm.dbc` through the resolver: the forms of type 1 name a + /// Beast, and every other row falls through to the race. + #[test] + fn the_shipped_forms_name_a_beast_or_fall_through_to_the_race() { + 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 sources = CreatureTypeSources { + names: None, + forms: Some(&forms), + }; + // Cat, Travel, Aquatic, Bear, Dire Bear, Creature - Bear, Creature - Cat, Ghost Wolf. + for id in [1, 3, 4, 5, 8, 14, 15, 16] { + assert_eq!(forms[&id].creature_type, 1, "form {id} is type 1"); + assert_eq!(sources.of(&store(0, 1, id)), 1, "form {id}"); + } + // Tree of Life and Battle Stance carry -1: the human race's type answers. + for id in [2, 17] { + assert!(forms[&id].creature_type <= 0, "form {id} carries no type"); + assert_eq!(sources.of(&store(0, 1, id)), 7, "form {id}"); + } + // Every row by the rule: above 0 names the type, else the race. + for (&id, row) in &forms { + let expected = u32::try_from(row.creature_type).ok().filter(|&t| t > 0); + assert_eq!( + sources.of(&store(0, 1, id)), + expected.unwrap_or(7), + "form {id}" + ); + } + } + + /// The unit snapshot carries the resolver's word for every unit, players included: a + /// shapeshifted player a Beast, an unshifted one a Humanoid by race, a form of no type the + /// same, and a creature its template's. + #[test] + fn the_snapshot_carries_the_resolved_type_word() { + let names = names_with(69, 1); + let forms = forms(); + let sources = CreatureTypeSources { + names: Some(&names), + forms: Some(&forms), + }; + let word = |store: &ObjectStore| { + crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name + }; + assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form"); + assert_eq!( + word(&store(0, 1, 0)).as_deref(), + Some("Humanoid"), + "no form" + ); + assert_eq!( + word(&store(0, 1, 2)).as_deref(), + Some("Humanoid"), + "type -1" + ); + assert_eq!(word(&store(69, 0, 0)).as_deref(), Some("Beast"), "template"); + assert_eq!(word(&store(70, 0, 0)), None, "uncached: race 0, no type"); + } + /// The frozen race table against the shipped `ChrRaces.dbc`. #[test] fn race_creature_type_matches_the_shipped_table() { diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 22a44809..8994ab70 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -11,6 +11,7 @@ use benilla_ui::script::{ }; use bevy::prelude::*; +use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; use crate::net::{ ClientCommand, FieldChanged, FieldEdges, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer, @@ -93,8 +94,9 @@ pub(crate) const GROUP_MEMBER_SUBGROUP: u8 = 0x07; /// Push the roster and the party unit snapshots into the VM and fire the party events on their /// edges. A member's unit state is their live descriptor when streamed, else their roster record. pub(super) fn feed_party( - // `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input; without it no class has a relic slot. - classes: Option>, + // `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type + // reads. + tables: crate::ui_unit::SnapshotTables, script: Option>, group: Res, index: Res, @@ -126,13 +128,14 @@ pub(super) fn feed_party( line, )); } - let chr = classes.as_deref().map(|t| &t.0); + let chr = tables.classes(); + let types = tables.types(&names); // The gate also opens on a despawn, which `Changed` misses. Solo, it almost always stays shut. let names_moved = fed.names_generation.moved(names.generation()); let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from)); let group_changed = group.is_changed(); let index_changed = index.is_changed(); - let look = pets::Lookup::new(&index, &stores, &names); + let look = pets::Lookup::new(&index, &stores, &names, types); // Only the stores the merged view reads: a crowd's other stores change every frame. The // members' pets are the group's too, `partypetN` and `raidpetN` reading their descriptors. let stores_changed = self_q @@ -294,6 +297,7 @@ pub(super) fn feed_party( &group, own_group.clone(), chr, + types, ) }); if fed.units[i] != snap { @@ -364,7 +368,7 @@ pub(super) fn feed_party( if Some(*guid) == self_guid { let (_, _, store) = self_pair?; let name = names.peek(*guid).map(str::to_string); - let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr); + let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr, types); s.is_player = true; s.raid_target = group.raid_target_index(*guid); s.faction_group = own_group.clone(); @@ -380,6 +384,7 @@ pub(super) fn feed_party( &group, own_group.clone(), chr, + types, )) } }); @@ -715,9 +720,13 @@ fn member_unit_state( // `ChrClasses.dbc`, for the relic slot alone: only the live leg has a class byte to key it // by, so an unstreamed paladin reads no relic slot. classes: Option<&benilla_formats::ChrClasses>, + // The creature type resolves from the live descriptor alone, so an unstreamed member has none. + types: CreatureTypeSources<'_>, ) -> UnitState { let mut s = match store { - Some(store) => crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes), + Some(store) => { + crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes, types) + } // Unstreamed: the roster record, snapshotted from the descriptor at despawn (`0x5f0880`), // seated at 1/1 for a member never seen (`0x4e82d0`) and patched by the wire. The // reference's getters read the descriptor, then the party record (`0x496400`), then the @@ -1414,7 +1423,15 @@ mod tests { max_power: Some(1000), ..PartyMemberStatsInfo::default() }; - let s = member_unit_state(&m, Some(&record), None, &GroupState::default(), None, None); + let s = member_unit_state( + &m, + Some(&record), + None, + &GroupState::default(), + None, + None, + Default::default(), + ); assert_eq!( (s.health, s.max_health), (2400, 3000), @@ -1429,7 +1446,15 @@ mod tests { assert!(s.exists && s.is_player && s.is_connected); // No record at all (a real roster always seats one): an existing player with empty bars. - let bare = member_unit_state(&m, None, None, &GroupState::default(), None, None); + let bare = member_unit_state( + &m, + None, + None, + &GroupState::default(), + None, + None, + Default::default(), + ); assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0)); assert!(bare.exists); } @@ -1449,7 +1474,15 @@ mod tests { status: Some(member_status::ONLINE | member_status::DEAD), ..PartyMemberStatsInfo::default() }; - let s = member_unit_state(&m, Some(&dead), None, &GroupState::default(), None, None); + let s = member_unit_state( + &m, + Some(&dead), + None, + &GroupState::default(), + None, + None, + Default::default(), + ); assert!( s.dead, "the record says dead even though the roster echo does not" @@ -1460,7 +1493,15 @@ mod tests { status: Some(member_status::ONLINE | member_status::GHOST), ..PartyMemberStatsInfo::default() }; - let s = member_unit_state(&m, Some(&ghost), None, &GroupState::default(), None, None); + let s = member_unit_state( + &m, + Some(&ghost), + None, + &GroupState::default(), + None, + None, + Default::default(), + ); assert!(s.ghost); assert!(!s.dead, "a released ghost is not `dead` — only a ghost"); @@ -1476,6 +1517,7 @@ mod tests { &GroupState::default(), None, None, + Default::default(), ); assert!(s.dead); } diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs index da116697..cc3adf17 100644 --- a/crates/benilla-app/src/ui_party/pets.rs +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -13,6 +13,7 @@ use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript, UnitState}; +use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; use crate::net::{FieldEdges, GuidIndex, NetCommands, ObjectStore}; use crate::ui_script::gate; @@ -44,12 +45,14 @@ impl std::ops::Deref for Names<'_> { } } -/// What the pets' snapshots read: the object manager and the name cache. +/// What the pets' snapshots read: the object manager, the name cache and the creature-type +/// resolver's sources. pub(super) struct Lookup<'a, 'w, 's, 'q> { index: &'a GuidIndex, stores: &'a Query<'w, 's, &'q ObjectStore>, names: &'a NameCache, commands: &'a NetCommands, + types: CreatureTypeSources<'a>, } impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> { @@ -57,12 +60,14 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> { index: &'a GuidIndex, stores: &'a Query<'w, 's, &'q ObjectStore>, names: &'a Names, + types: CreatureTypeSources<'a>, ) -> Self { Self { index, stores, names: &names.cache, commands: &names.commands, + types, } } @@ -125,7 +130,7 @@ pub(super) fn member_pet( .names .resolve_unit(guid, Some(store), look.commands) .map(str::to_string); - crate::ui_unit::snapshot(store, guid, name, 0, None) + crate::ui_unit::snapshot(store, guid, name, 0, None, look.types) } None => record_state( group, @@ -736,4 +741,137 @@ mod tests { let errors = app.world().non_send_resource::().errors(); assert!(errors.is_empty(), "script errors: {errors:?}"); } + + /// The resolver's inputs for the creature-type tests: Cat Form's row, and entry 69's template + /// a Demon (an imp), so a pet's type is told from the race's Humanoid. + fn seed_creature_types(app: &mut App) { + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.forms.insert( + CAT_FORM, + benilla_formats::ShapeshiftForm { + creature_type: 1, + ..Default::default() + }, + ); + app.insert_resource(spells); + app.world_mut().resource_mut::().insert_creature( + 69, + Some(crate::names::CreatureRecord { + name: "Imp".into(), + subname: None, + creature_type: 3, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + } + + /// `UNIT_FIELD_BYTES_0` and `_1`, `OBJECT_FIELD_ENTRY`, a Night Elf and Cat Form. + const BYTES_0: u16 = 36; + const BYTES_1: u16 = 138; + const OBJECT_FIELD_ENTRY: u16 = 3; + const NIGHT_ELF: u32 = 4; + const CAT_FORM: u32 = 1; + + /// `UnitCreatureType` on the party's tokens goes through the one resolver (`0x605570`): a + /// held member in Cat Form is a Beast and an unshifted one a Humanoid by race, a held pet + /// answers its template's type, and a member the object manager does not hold answers nil, + /// as the token resolver finds no object for it (`0x51a2b1`). + #[test] + fn party_tokens_read_the_creature_type_through_the_resolver() { + let mut app = app(); + seed_creature_types(&mut app); + party(&mut app, 2, true); + let fields = |form: u32| { + [ + &[(BYTES_0, NIGHT_ELF), (BYTES_1, form << 16)][..], + &summon(pet(1)), + ] + .concat() + }; + let druid = stream(&mut app, member(1), &fields(CAT_FORM)); + stream(&mut app, pet(1), &[(OBJECT_FIELD_ENTRY, 69)]); + frame(&mut app); + + let creature_type = + |app: &mut App, token: &str| text(app, &format!("UnitCreatureType('{token}')")); + assert_eq!(creature_type(&mut app, "party1").as_deref(), Some("Beast")); + assert_eq!( + creature_type(&mut app, "partypet1").as_deref(), + Some("Demon"), + "the pet's template" + ); + assert_eq!( + creature_type(&mut app, "party2"), + None, + "a member the object manager does not hold" + ); + + set_fields(&mut app, druid, &[(BYTES_1, 0)]); + frame(&mut app); + assert_eq!( + creature_type(&mut app, "party1").as_deref(), + Some("Humanoid"), + "out of form: the race's type" + ); + } + + /// The raid rows read the same resolver, our own row off our descriptor. + #[test] + fn raid_tokens_read_the_creature_type_through_the_resolver() { + let mut app = app(); + seed_creature_types(&mut app); + party(&mut app, 1, true); + app.world_mut().resource_mut::().apply_list( + super::super::GROUPTYPE_RAID, + 0, + vec![GroupMemberEntry { + name: "M1".into(), + guid: member(1), + status: member_status::ONLINE, + flags: 0, + }], + ME, + None, + Some(ME), + ); + let me = app + .world_mut() + .spawn(( + crate::net::SelfPlayer, + Guid(ME), + store(&[(BYTES_0, NIGHT_ELF), (BYTES_1, CAT_FORM << 16)]), + )) + .id(); + stream( + &mut app, + member(1), + &[(BYTES_0, NIGHT_ELF), (BYTES_1, CAT_FORM << 16)], + ); + frame(&mut app); + + let creature_type = + |app: &mut App, token: &str| text(app, &format!("UnitCreatureType('{token}')")); + assert_eq!( + creature_type(&mut app, "raid1").as_deref(), + Some("Beast"), + "our own row" + ); + assert_eq!( + creature_type(&mut app, "raid2").as_deref(), + Some("Beast"), + "a held member's row" + ); + set_fields(&mut app, me, &[(BYTES_1, 0)]); + frame(&mut app); + assert_eq!( + creature_type(&mut app, "raid1").as_deref(), + Some("Humanoid"), + "out of form: our race's type" + ); + } } diff --git a/crates/benilla-app/src/ui_pet/tests.rs b/crates/benilla-app/src/ui_pet/tests.rs index 894988ba..1f142cbe 100644 --- a/crates/benilla-app/src/ui_pet/tests.rs +++ b/crates/benilla-app/src/ui_pet/tests.rs @@ -989,3 +989,70 @@ fn a_held_pet_reads_connected_and_its_stock_power_bar_shows_its_power() { let errors = script.errors(); assert!(errors.is_empty(), "script errors: {errors:?}"); } + +/// `UnitCreatureType("pet")` (`0x51a280`) names the pet's template through the resolver +/// `0x605570`, keyed by the descriptor's `OBJECT_FIELD_ENTRY`: a held Imp is a Demon. +#[test] +fn a_held_pet_names_its_templates_creature_type() { + use bevy::prelude::*; + const PET: u64 = 0xF140_0000_0000_002A; + /// `OBJECT_FIELD_ENTRY`, and the Imp's template entry. + const OBJECT_FIELD_ENTRY: u16 = 3; + const IMP: u32 = 416; + + let mut names = crate::names::NameCache::default(); + names.insert_creature( + IMP, + Some(crate::names::CreatureRecord { + name: "Imp".into(), + subname: None, + creature_type: 3, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + let mut app = App::new(); + app.add_message::() + .init_resource::() + .init_resource::() + .insert_resource(names) + .insert_resource(PetBar { + spells: PetSpells { + pet_guid: PET, + ..Default::default() + }, + ..Default::default() + }) + .add_systems(Update, feed_pet_unit); + let (tx, rx) = crossbeam_channel::unbounded(); + // Kept alive for the run: a dropped receiver would fail the name query. + std::mem::forget(rx); + app.insert_resource(NetCommands(tx)); + let pet = app + .world_mut() + .spawn(( + crate::net::Guid(PET), + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[( + OBJECT_FIELD_ENTRY, + IMP, + )])), + )) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(PET, pet); + app.insert_non_send_resource(benilla_ui::script::UiScript::new().unwrap()); + + app.update(); + let answer = app + .world_mut() + .non_send_resource_mut::() + .eval::>("return UnitCreatureType('pet')") + .unwrap(); + assert_eq!(answer.as_deref(), Some("Demon")); +} diff --git a/crates/benilla-app/src/ui_pet/unit.rs b/crates/benilla-app/src/ui_pet/unit.rs index 76704672..e971243b 100644 --- a/crates/benilla-app/src/ui_pet/unit.rs +++ b/crates/benilla-app/src/ui_pet/unit.rs @@ -5,6 +5,7 @@ use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript, UnitState}; +use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; use crate::net::{NetCommands, ObjectStore}; use crate::ui_script::gate; @@ -43,6 +44,8 @@ pub(super) fn feed_pet_unit( mut removed_stores: RemovedComponents, mut names: ResMut, commands: Res, + // The form table, the creature-type resolver's first stage. + spells: Option>, mut memory: Local>, ) { let Some(mut script) = script else { @@ -81,7 +84,8 @@ pub(super) fn feed_pet_unit( .resolve_unit(pet_guid, Some(store), &commands) .map(str::to_string); // No `ChrClasses.dbc`: the reference reads a class only for TYPEMASK_PLAYER. - snapshot(store, pet_guid, name, 0, None) + let types = CreatureTypeSources::of_resources(&names, spells.as_deref()); + snapshot(store, pet_guid, name, 0, None, types) }); let dirty = match (&fresh, &memory.pushed) { diff --git a/crates/benilla-app/src/ui_script/pet_frame_tests.rs b/crates/benilla-app/src/ui_script/pet_frame_tests.rs index 81789ab0..7327e6a9 100644 --- a/crates/benilla-app/src/ui_script/pet_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_frame_tests.rs @@ -70,7 +70,7 @@ fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> U (POWER1 + slot, power), (MAXPOWER1 + slot, max_power), ])); - crate::ui_unit::snapshot(&store, 0, Some(name.into()), 0, None) + crate::ui_unit::snapshot(&store, 0, Some(name.into()), 0, None, Default::default()) } /// Every texture path drawn this frame, with its vertex tint. diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index b050c06b..1293341c 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -14,7 +14,7 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer}; use crate::target::{go_is_nearest, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC}; use crate::ui_script::UiFeed; use crate::ui_trainer::TrainerFeed; -use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, UnitFeed}; +use crate::ui_unit::{enrich_unit, snapshot, unit_reaction, SnapshotTables, UnitFeed}; mod spell_deps; mod spell_feed; @@ -117,8 +117,9 @@ fn drive_mouseover_tooltip( // The cursor seat crosses the VM seam: the anchor below is UI units, not px. ui_scale: Res, mut memo: HoverMemo, - // `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input; without it no class has a relic slot. - classes: Option>, + // `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type + // reads. + tables: SnapshotTables, ) { let Some(mut script) = script else { return; @@ -128,7 +129,8 @@ fn drive_mouseover_tooltip( let last_lines = last_lines.get(&script); let published = memo.published.get(&script); let self_store = self_q.iter().next(); - let chr = classes.as_deref().map(|t| &t.0); + let chr = tables.classes(); + let types = tables.types(&names); let unit = hovered.mouseover(&hovered_go).and_then(|(entity, guid)| { let store = stores.get(entity).ok()?; @@ -138,7 +140,7 @@ fn drive_mouseover_tooltip( let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store); // The hovered guid, the pair `0x492890` writes to `0xb4e2c8`/`0xb4e2cc` and the token // resolver `0x515970` reads for `"mouseover"`, so `UnitIsUnit` can match it. - let mut s = snapshot(store, guid, name, reaction, chr); + let mut s = snapshot(store, guid, name, reaction, chr, types); enrich_unit( &mut s, guid, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index dcf9f941..bb2749cd 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -911,6 +911,56 @@ fn the_mouseover_token_carries_the_hovered_guid() { ); } +/// `UnitCreatureType("mouseover")` goes through the resolver `0x605570` for a hovered player as +/// for a creature: a druid in Cat Form is a Beast, and out of form a Humanoid by race. The plate +/// itself is built for a player from its race and class, whatever type the snapshot carries. +#[test] +fn the_mouseover_token_names_the_hovered_units_creature_type() { + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0` and `UNIT_FIELD_BYTES_1`, absolute descriptor indices. + const BYTES_0: u16 = 36; + const BYTES_1: u16 = 138; + const NIGHT_ELF: u32 = 4; + const CAT_FORM: u32 = 1; + const DRUID: u64 = 0x99; + + let (mut app, _, _) = mouseover_app(); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.forms.insert( + CAT_FORM, + benilla_formats::ShapeshiftForm { + creature_type: 1, + ..Default::default() + }, + ); + app.insert_resource(spells); + let druid = app + .world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(&[ + (BYTES_0, NIGHT_ELF), + (BYTES_1, CAT_FORM << 16), + ]))) + .id(); + let creature_type = |app: &mut App| { + app.world_mut() + .non_send_resource_mut::() + .eval::>(r#"return UnitCreatureType("mouseover")"#) + .unwrap() + }; + + hover_unit(&mut app, druid, DRUID); + assert_eq!(creature_type(&mut app).as_deref(), Some("Beast")); + app.world_mut() + .entity_mut(druid) + .get_mut::() + .unwrap() + .0 + .merge(ObjectFields::from_pairs(&[(BYTES_1, 0)])); + hover_unit(&mut app, druid, DRUID); + assert_eq!(creature_type(&mut app).as_deref(), Some("Humanoid")); +} + /// Once no unit wins the pick, `"mouseover"` names nobody: the publisher `0x492890` zeroes the pair /// (`0x4928e8`, `0x4928f2`) and writes a null, corpse or GameObject guid, which the resolver /// rejects as a unit (`0x515bca mov ecx,8`, `0x515bd9 je`). Empty ground, a corpse and a nearer diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index eeeee621..4437a3a3 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -10,6 +10,7 @@ use benilla_formats::ChrClasses; use benilla_protocol::messages::ObjectType; use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay}; +use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; use crate::net::{ FieldChanged, FieldEdges, Guid, NetCommands, ObjectStore, Reputations, SelfPlayer, @@ -803,6 +804,27 @@ pub(crate) struct UnitStores<'w, 's> { edges: MessageReader<'w, 's, FieldChanged>, } +/// The client tables [`snapshot`] reads beside a descriptor, as one parameter for the feeders that +/// sit at Bevy's 16-parameter limit: `ChrClasses.dbc` for the relic slot, and the form table +/// the creature-type resolver's first stage reads. Each is absent without game data. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct SnapshotTables<'w> { + classes: Option>, + spells: Option>, +} + +impl SnapshotTables<'_> { + /// `ChrClasses.dbc`; without it no class has a relic slot, the reference's bounds leg. + pub(crate) fn classes(&self) -> Option<&ChrClasses> { + self.classes.as_deref().map(|t| &t.0) + } + + /// The creature-type resolver's sources: the name cache and the form table. + pub(crate) fn types<'a>(&'a self, names: &'a NameCache) -> CreatureTypeSources<'a> { + CreatureTypeSources::of_resources(names, self.spells.as_deref()) + } +} + /// `UnitReaction(unit, "player")` (`0x5167e0`): [`ring_reaction`] plus one (`0x51683e`), so /// `1..=7`, Hated to Revered, and Exalted reads 7 too (`0x606439`). Stock `UnitReactionColor` /// has seven entries (`TargetFrame.lua:6-14`), one per value. @@ -817,13 +839,14 @@ pub(crate) fn unit_reaction( /// Build a unit snapshot from a streamed descriptor, its guid, its cached name and its /// `UnitReaction` (`1..=7`, or `0` where none is resolved, as for `"player"`); `classes` feeds the -/// relic column. +/// relic column and `types` the creature type, which every unit carries, players included. pub(crate) fn snapshot( store: &ObjectStore, guid: u64, name: Option, reaction: u8, classes: Option<&ChrClasses>, + types: CreatureTypeSources<'_>, ) -> UnitState { let power_type = store.0.unit_power_type(); let race = store.0.unit_race().and_then(race_names); @@ -862,6 +885,9 @@ pub(crate) fn snapshot( // The raw dword `PLAYER_FLAGS_CHANGED` fires on; 0 on a creature, as in the reference. player_flags: store.0.player_flags(), reaction, + // The one resolver `0x605570` behind `UnitCreatureType` (`0x51a2bc`) and the tooltip's + // type slot (`0x52a2e5`): the form's type, else the template's, else the race's. + creature_type_name: creature_type_word(types.of(store)).map(str::to_string), race: race.map(|(n, _)| n.to_string()), race_file: race.map(|(_, f)| f.to_string()), class: class.map(|(n, _)| n.to_string()), @@ -910,8 +936,8 @@ pub(crate) fn snapshot( } } -/// Fill the guid-keyed tooltip fields: `is_player`, or a creature's record fields (the type word -/// from `CreatureType.dbc`'s enUS names) and its faction-name line. +/// Fill the guid-keyed tooltip fields: `is_player`, or a creature's record fields and its +/// faction-name line. pub(crate) fn enrich_unit( state: &mut UnitState, guid: u64, @@ -933,7 +959,6 @@ pub(crate) fn enrich_unit( let rec = names.creature_record(entry); if let Some(rec) = rec { state.subtitle = rec.subname.clone(); - state.creature_type_name = creature_type_word(rec.creature_type).map(str::to_string); // The client's one rank getter, never `rec.rank`: an enslaved elite reads rank 0. state.rank = crate::names::gated_rank(Some(rec), Some(store)); state.civilian = rec.civilian; @@ -1157,8 +1182,7 @@ pub(crate) fn fire_transitions( fn feed_units( script: Option>, - // Absent when the data failed to load: no class has a relic slot, the reference's bounds leg. - classes: Option>, + tables: SnapshotTables, self_q: Query<(&ObjectStore, &Guid), With>, selection: Res, @@ -1189,7 +1213,8 @@ fn feed_units( { script.set_billing_time_rested(entered); } - let chr = classes.as_deref().map(|t| &t.0); + let chr = tables.classes(); + let types = tables.types(&names); // One reborrow, so the memo and `warned_sideless` borrow disjointly rather than alias. let feed = &mut *feed; let (memo, vm_reset) = feed.vm.get_reset(&script); @@ -1244,7 +1269,7 @@ fn feed_units( let name = names .resolve_unit(guid.0, Some(store), &commands) .map(str::to_string); - let mut s = snapshot(store, guid.0, name, 0, chr); + let mut s = snapshot(store, guid.0, name, 0, chr, types); s.is_player = true; s.raid_target = group.raid_target_index(guid.0); s.faction_group = faction_group(store, factions.as_deref()); @@ -1279,7 +1304,7 @@ fn feed_units( store, self_pair.map(|(s, _)| s), ); - let mut s = snapshot(store, guid, name, reaction, chr); + let mut s = snapshot(store, guid, name, reaction, chr, types); s.raid_target = group.raid_target_index(guid); s.faction_group = faction_group(store, factions.as_deref()); s.faction_group_localized = faction_group_localized(store, factions.as_deref()); @@ -1321,7 +1346,7 @@ fn feed_units( store, self_pair.map(|(s, _)| s), ); - let mut s = snapshot(store, guid, name, reaction, chr); + let mut s = snapshot(store, guid, name, reaction, chr, types); s.raid_target = group.raid_target_index(guid); s.faction_group = faction_group(store, factions.as_deref()); s.faction_group_localized = faction_group_localized(store, factions.as_deref()); @@ -1387,7 +1412,7 @@ fn feed_units( store, self_pair.map(|(s, _)| s), ); - let mut s = snapshot(store, guid, name, reaction, chr); + let mut s = snapshot(store, guid, name, reaction, chr, types); s.raid_target = group.raid_target_index(guid); s.faction_group = faction_group(store, factions.as_deref()); s.faction_group_localized = faction_group_localized(store, factions.as_deref()); @@ -2066,6 +2091,7 @@ mod tests { None, 0, None, + Default::default(), ) .pvp_team }; @@ -2732,6 +2758,7 @@ mod tests { Some("Hunter".into()), 0, None, + Default::default(), ); assert_eq!((alive.health, alive.max_health), (1200, 1500)); assert_eq!((alive.power, alive.max_power), (300, 900)); @@ -2745,6 +2772,7 @@ mod tests { Some("Hunter".into()), 0, None, + Default::default(), ); assert_eq!( (feigning.health, feigning.max_health), @@ -3057,6 +3085,155 @@ mod tests { ); } + /// `UnitCreatureType` through the real feed (`0x51a280`, resolver `0x605570`): a shapeshifted + /// player answers the form's type before the race's, an unshifted one the race's, a form of + /// type -1 falls through to the race, and a creature keeps its template's unless a form + /// outranks it. + #[test] + fn unit_creature_type_names_the_form_before_the_race_and_a_creature_its_template() { + use crate::names::CreatureRecord; + use benilla_formats::ShapeshiftForm; + use benilla_protocol::messages::ObjectType; + use benilla_protocol::ObjectFields; + + /// `UNIT_FIELD_BYTES_0` and `UNIT_FIELD_BYTES_1`, absolute descriptor indices, and + /// `OBJECT_FIELD_ENTRY`. + const BYTES_0: u16 = 36; + const BYTES_1: u16 = 138; + const OBJECT_FIELD_ENTRY: u16 = 3; + const NIGHT_ELF: u32 = 4; + const CAT_FORM: u32 = 1; + /// A row whose type is -1 in the shipped table. + const NO_TYPE_FORM: u32 = 2; + /// `HIGHGUID_UNIT` guids of entries 69 (a wolf, a Beast) and 70 (a druid, a Humanoid). + const WOLF: u64 = 0xF130_0000_4500_0001; + const DRUID: u64 = 0xF130_0000_4600_0001; + + let record = |name: &str, creature_type| CreatureRecord { + name: name.into(), + subname: None, + creature_type, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }; + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + app.add_message::(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)); + app.insert_non_send_resource(UiScript::new().unwrap()); + let mut names = NameCache::default(); + names.insert_creature(69, Some(record("Wolf", 1))); + names.insert_creature(70, Some(record("Druid", 7))); + app.insert_resource(names); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + let form = |creature_type| ShapeshiftForm { + creature_type, + ..Default::default() + }; + spells.forms.insert(CAT_FORM, form(1)); + spells.forms.insert(NO_TYPE_FORM, form(-1)); + app.insert_resource(spells); + app.add_systems(Update, feed_units); + + let me = app + .world_mut() + .spawn(( + SelfPlayer, + Guid(0x77), + ObjectStore( + ObjectFields::from_pairs(&[(BYTES_0, NIGHT_ELF)]) + .into_created(ObjectType::Player), + ), + )) + .id(); + let mut creature = |entry: u32| { + app.world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(&[( + OBJECT_FIELD_ENTRY, + entry, + )]))) + .id() + }; + let wolf = creature(69); + let druid = creature(70); + let creature_type = |app: &mut App, token: &str| -> Option { + app.world_mut() + .non_send_resource_mut::() + .eval::>(&format!(r#"return UnitCreatureType("{token}")"#)) + .unwrap() + }; + let shift = |app: &mut App, unit: Entity, form: u32| { + app.world_mut() + .entity_mut(unit) + .get_mut::() + .unwrap() + .0 + .merge(ObjectFields::from_pairs(&[(BYTES_1, form << 16)])); + app.update(); + }; + let select = |app: &mut App, unit: Entity, guid: u64| { + let mut selection = app.world_mut().resource_mut::(); + selection.target = Some(unit); + selection.guid = Some(guid); + app.update(); + }; + + app.update(); + assert_eq!( + creature_type(&mut app, "player").as_deref(), + Some("Humanoid"), + "no form: the race's type" + ); + shift(&mut app, me, CAT_FORM); + assert_eq!( + creature_type(&mut app, "player").as_deref(), + Some("Beast"), + "Cat Form: the form's type, before the race's" + ); + shift(&mut app, me, NO_TYPE_FORM); + assert_eq!( + creature_type(&mut app, "player").as_deref(), + Some("Humanoid"), + "a form of type -1 falls through to the race" + ); + shift(&mut app, me, 0); + assert_eq!( + creature_type(&mut app, "player").as_deref(), + Some("Humanoid"), + "out of form again" + ); + + select(&mut app, wolf, WOLF); + assert_eq!( + creature_type(&mut app, "target").as_deref(), + Some("Beast"), + "a creature's template type" + ); + select(&mut app, druid, DRUID); + assert_eq!( + creature_type(&mut app, "target").as_deref(), + Some("Humanoid"), + "a Humanoid template" + ); + shift(&mut app, druid, CAT_FORM); + assert_eq!( + creature_type(&mut app, "target").as_deref(), + Some("Beast"), + "a form outranks the template, on a creature as on a player" + ); + } + /// Event `0x111` fires from the local player's destructor, which a same-map teleport never /// reaches, and the initial-login map needs no ack. #[test] diff --git a/crates/benilla-formats/src/spells/forms.rs b/crates/benilla-formats/src/spells/forms.rs index 9e14f06a..b67ab095 100644 --- a/crates/benilla-formats/src/spells/forms.rs +++ b/crates/benilla-formats/src/spells/forms.rs @@ -23,7 +23,7 @@ pub struct ShapeshiftForm { pub flags: u32, /// `creatureType` (column 12): the form's creature-type override, which the resolver /// (`0x605570`) reads before the creature template or race, so a cat-form druid tracks as a - /// Beast; `<= 0` falls back to Humanoid. + /// Beast; `<= 0` is no override, and the template or race answers. pub creature_type: i32, /// `AttackIconID` (column 13) through `SpellIcon.dbc`: the Attack action shows the current /// form's icon before the main-hand weapon's (`0x4e6870`); `None` falls through to the weapon. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 7b7213a1..0ce487f0 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -896,42 +896,48 @@ fn set_desaturated_reports_shader_support_and_does_not_raise() { .unwrap(); } -/// `UnitCreatureType` (`0x51a280`, stages 2 and 3 of three built): a creature's cached record, -/// else (`0x605570`) the race's `ChrRaces.dbc` column 9, which is 7, "Humanoid", for every race. +/// `UnitCreatureType` (`0x51a280`) answers the type the snapshot carries, which the app resolved +/// as `0x605570` does (form, else template, else race), for a creature and a player alike: nil +/// for a token that names no unit or a snapshot with no type (`0x51a2b8`, `0x51a2c3`). #[test] -fn unit_creature_type_answers_the_record_then_falls_back_to_humanoid() { +fn unit_creature_type_answers_the_snapshots_resolved_type() { let mut s = script(); - s.set_unit( - "target", + let typed = |word: Option<&str>, is_player: bool| { Some(crate::script::UnitState { exists: true, - creature_type_name: Some("Beast".into()), + is_player, + creature_type_name: word.map(str::to_string), ..Default::default() - }), - ); - s.set_unit( - "player", - Some(crate::script::UnitState { - exists: true, - is_player: true, - ..Default::default() - }), - ); + }) + }; + s.set_unit("target", typed(Some("Beast"), false)); + // A shapeshifted player carries the form's type, an unshifted one the race's. + s.set_unit("player", typed(Some("Beast"), true)); + s.set_unit("party1", typed(Some("Humanoid"), true)); + // A player with no descriptor behind the snapshot carries none, and answers nil. + s.set_unit("party2", typed(None, true)); - assert_eq!( - s.eval::(r#"return UnitCreatureType("target")"#) - .unwrap(), - "Beast" - ); - assert_eq!( - s.eval::(r#"return UnitCreatureType("player")"#) - .unwrap(), - "Humanoid" - ); - // An unresolved token is nil; a missing argument fails `lua_isstring` and raises. - assert!(s - .eval::(r#"return UnitCreatureType("party4") == nil"#) - .unwrap()); + for (token, word) in [ + ("target", "Beast"), + ("player", "Beast"), + ("party1", "Humanoid"), + ] { + assert_eq!( + s.eval::(&format!(r#"return UnitCreatureType("{token}")"#)) + .unwrap(), + word, + "{token}" + ); + } + // An unresolved token and a typeless snapshot are nil; a missing argument fails + // `lua_isstring` and raises. + for token in ["party2", "party4"] { + assert!( + s.eval::(&format!(r#"return UnitCreatureType("{token}") == nil"#)) + .unwrap(), + "{token}" + ); + } let err = s .run("UnitCreatureType()") .expect_err("a missing arg must raise"); diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index d4c48414..cfa1789d 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -391,6 +391,55 @@ fn level_line_variants() { assert!(s.take_errors().is_empty()); } +/// A player's level line reads "Race Class (Player)" whatever creature type the snapshot carries: +/// the builder tests the player type bit (`0x52a4a6`) and takes the race and class names for a +/// player (`0x52a4e5`-`0x52a555`), the type row only otherwise (`0x52a4bd`-`0x52a4d4`), so a +/// shapeshifted player's Beast never reaches the line. +#[test] +fn a_players_level_line_ignores_the_snapshots_creature_type() { + let mut s = script(); + seed_level_strings(&mut s); + s.set_screen_size(800.0, 600.0); + s.set_player_req_state(PlayerReqState { + level: 60, + ..Default::default() + }); + s.run( + r#" + local a = CreateFrame("Button", "UF3"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(a, "ANCHOR_RIGHT") + "#, + ) + .unwrap(); + let druid = |reaction: u8, creature_type: Option<&str>| UnitState { + exists: true, + name: Some("Fenwick".into()), + level: 40, + reaction, + is_player: true, + race: Some("Night Elf".into()), + class: Some("Druid".into()), + creature_type_name: creature_type.map(str::to_string), + ..Default::default() + }; + // Hostile, neutral and friendly: the three sides of the reaction gate the creature arm reads. + for reaction in [2, 4, 6] { + for creature_type in [None, Some("Humanoid"), Some("Beast")] { + s.set_unit("target", Some(druid(reaction, creature_type))); + s.run( + r#" + TT:SetOwner(UF3, "ANCHOR_RIGHT"); TT:SetUnit("target") + assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE 40 Night Elf Druid [PLAYER]]", + "got " .. TTTextLeft2:GetText()) + "#, + ) + .unwrap_or_else(|e| panic!("reaction {reaction}, type {creature_type:?}: {e}")); + } + } + assert!(s.take_errors().is_empty()); +} + /// `world_tooltip_unit` fires the default anchor, renders, then fires `UPDATE_MOUSEOVER_UNIT`. #[test] fn world_hover_drive_and_health_watcher() { diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 89ffe791..c54d9b18 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -756,13 +756,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // UnitCreatureType(unit) (`0x51a280`): one string, nil for a token naming no unit (`0x51a2b8`). - // Its resolver `0x605570` tries the shapeshift form's type (`SpellShapeshiftForm.dbc` column - // 12, only above 0, `0x60559a`), the cached creature record, then the race's (`ChrRaces.dbc` - // column 9, "Humanoid" for every race). The form stage is not built (no form index here), so an - // animal-form druid or a Ghost Wolf shaman answers "Humanoid" where the reference says "Beast". - // A missing argument, or one neither string nor number, raises `Usage:` (`0x6f3510`, - // `0x6f4940`). + // UnitCreatureType(unit) (`0x51a280`): one string, nil for a token naming no unit (`0x51a2b8`) + // and for a type of 0 (`0x51a2c3`). The type is the snapshot's, which the app resolved as the + // reference's `0x605570` does: the shapeshift form's (`SpellShapeshiftForm.dbc` column 12, only + // above 0), else the cached creature record's, else the race's (`ChrRaces.dbc` column 9). A + // snapshot with no descriptor behind it carries none, as the reference's token resolver finds + // no object for it. A missing argument, or one neither string nor number, raises `Usage:` + // (`0x6f3510`, `0x6f4940`). g.set( "UnitCreatureType", lua.create_function(|lua, token: Value| { @@ -773,12 +773,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { Value::Number(_) | Value::Integer(_) => Some(String::new()), _ => return Err(mlua::Error::runtime("Usage: UnitCreatureType(\"unit\")")), }; - let word = with_unit(lua, &token, None, |u| { - u.creature_type_name - .clone() - // The race stage, collapsed: every player race maps to type 7. - .or_else(|| u.is_player.then(|| "Humanoid".to_string())) - })?; + let word = with_unit(lua, &token, None, |u| u.creature_type_name.clone())?; match word { Some(w) => Ok(Value::String(lua.create_string(&w)?)), None => Ok(Value::Nil), diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 9022ea4c..cc47a19a 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -137,7 +137,10 @@ pub struct UnitState { pub guild: Option, /// The creature template's subname ("Stable Master"), the tooltip's second line. pub subtitle: Option, - /// The creature type word ("Beast") from `CreatureType.dbc`, the level line's class slot. + /// The word ("Beast") from `CreatureType.dbc` for the type the reference's resolver `0x605570` + /// returns for any unit: the shapeshift form's, else the creature template's, else the + /// race's. `UnitCreatureType` answers it, and a creature's level line takes it as its class + /// slot; a player's reads race and class instead. `None` for type 0 or no descriptor. pub creature_type_name: Option, /// Creature rank 0..=4 as the reference's getter (`0x605620`) answers it, so already gated: /// 0 without a cached creature template or with a pet number. The tooltip's rank word,