UnitCreatureType answers the one resolver's type for every unit, so a cat-form druid or a Ghost Wolf shaman is a Beast: the snapshot takes the form, then the template, then the race (0x605570, 0x51a2bc) (#681)

`UnitCreatureType` read only a creature's template type, and fell back to "Humanoid" for a player. The form stage was not built. So a druid in cat, bear, travel or aquatic form and a shaman in Ghost Wolf all answered "Humanoid", where 1.12.1 answers "Beast".

`UnitCreatureType 0x51a280` names the type the resolver `0x605570` returns, and 0 names nil (`0x51a2c3`). The resolver tries three stages in order:
1. **Form:** the form byte (`UNIT_FIELD_BYTES_1` byte 2) indexes `SpellShapeshiftForm.dbc` column 12, which applies only above 0 (`0x60559a`).
2. **Template:** else the cached creature record (`0x6055a8`).
3. **Race:** else the race's `ChrRaces.dbc` type (`0x6055ca`).

A token naming no unit answers nil (`0x51a2b8`). The unit tooltip's level line takes the type only for a non-player (`0x52a4a6`).

Changes:
- `ui_unit::snapshot` sets `creature_type_name` for every unit through the shared `CreatureTypeSources::of`: players, creatures, pets and group tokens alike. `enrich_unit` no longer sets it.
- `UnitCreatureType` reads that field, with no fallback.
- The feeds at Bevy's parameter limit carry the sources in a `SnapshotTables` param.
- A player's tooltip level line is unchanged, since it reads race and class.

Behaviour that now matches the reference beyond the issue:
- a held pet, `partypetN` and `raidpetN` answer their template's type rather than nil;
- a party member in a form reads the form's type;
- a roster member with no object, and the pre-arrival player seat, answer nil.

Tests:
- `the_shipped_forms_name_a_beast_or_fall_through_to_the_race` reads the real `SpellShapeshiftForm.dbc`.
- `unit_creature_type_names_the_form_before_the_race_and_a_creature_its_template` runs `feed_units` and asks the Lua binding.
- Mouseover, party, raid and pet feeders each have a test.
- `a_players_level_line_ignores_the_snapshots_creature_type`.

Fixes #641

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 06:20:12 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9d4b3420f3
commit 3a613971bb
15 changed files with 688 additions and 79 deletions
@@ -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()),
+77 -4
View File
@@ -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<u32, ShapeshiftForm>>,
}
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() {
+52 -10
View File
@@ -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<Res<crate::chr_classes::ChrClassTable>>,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
// reads.
tables: crate::ui_unit::SnapshotTables,
script: Option<NonSendMut<UiScript>>,
group: Res<GroupState>,
index: Res<GuidIndex>,
@@ -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);
}
+140 -2
View File
@@ -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::<UiScript>().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::<NameCache>().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::<GroupState>().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"
);
}
}
+67
View File
@@ -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::<crate::net::FieldChanged>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::SelfGuid>()
.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::<crate::net::GuidIndex>()
.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::<benilla_ui::script::UiScript>()
.eval::<Option<String>>("return UnitCreatureType('pet')")
.unwrap();
assert_eq!(answer.as_deref(), Some("Demon"));
}
+5 -1
View File
@@ -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<ObjectStore>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
// The form table, the creature-type resolver's first stage.
spells: Option<Res<crate::ui_action::Spells>>,
mut memory: Local<crate::ui_script::VmMemo<PetUnitMemory>>,
) {
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) {
@@ -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.
+7 -5
View File
@@ -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<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input; without it no class has a relic slot.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
// `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,
@@ -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::<UiScript>()
.eval::<Option<String>>(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::<ObjectStore>()
.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
+188 -11
View File
@@ -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<Res<'w, crate::chr_classes::ChrClassTable>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
}
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<String>,
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<NonSendMut<UiScript>>,
// Absent when the data failed to load: no class has a relic slot, the reference's bounds leg.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
tables: SnapshotTables,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
@@ -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::<UnitFeedState>()
.init_resource::<Selection>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<FieldChanged>();
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<String> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Option<String>>(&format!(r#"return UnitCreatureType("{token}")"#))
.unwrap()
};
let shift = |app: &mut App, unit: Entity, form: u32| {
app.world_mut()
.entity_mut(unit)
.get_mut::<ObjectStore>()
.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>();
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]
+1 -1
View File
@@ -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.
@@ -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::<String>(r#"return UnitCreatureType("target")"#)
.unwrap(),
"Beast"
);
assert_eq!(
s.eval::<String>(r#"return UnitCreatureType("player")"#)
.unwrap(),
"Humanoid"
);
// An unresolved token is nil; a missing argument fails `lua_isstring` and raises.
assert!(s
.eval::<bool>(r#"return UnitCreatureType("party4") == nil"#)
.unwrap());
for (token, word) in [
("target", "Beast"),
("player", "Beast"),
("party1", "Humanoid"),
] {
assert_eq!(
s.eval::<String>(&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::<bool>(&format!(r#"return UnitCreatureType("{token}") == nil"#))
.unwrap(),
"{token}"
);
}
let err = s
.run("UnitCreatureType()")
.expect_err("a missing arg must raise");
@@ -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() {
+8 -13
View File
@@ -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),
+4 -1
View File
@@ -137,7 +137,10 @@ pub struct UnitState {
pub guild: Option<super::guild::UnitGuild>,
/// The creature template's subname ("Stable Master"), the tooltip's second line.
pub subtitle: Option<String>,
/// 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<String>,
/// 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,