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:
co-authored by
Claude Opus 5.5
parent
9d4b3420f3
commit
3a613971bb
@@ -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()),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user