- the character-create and main-menu backdrop runs its material animation: the clouds drift on their 20, 30 and 40 second loops, which were seeded once and never sampled (2300) - Arathi Basin and Alterac Valley are entered and censused beside Warsong Gulch, the flag is picked up, and a match runs to its end (2296) - the animation variation roll is one clock-seeded stream, as the reference's C runtime is, not four streams seeded 0 (2299, 2301) - a cancelled channel never turns the bar red and a finished one closes on the bar's own clock, now pinned by tests that drive the real packet sequence (2284) - the field watch fires one edge per moved descriptor dword, from the merge that holds both sides (2297) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
788 lines
37 KiB
Rust
788 lines
37 KiB
Rust
//! The hunter pet's **paper-doll stat block** — `GetPetHappiness`, `GetPetLoyalty`,
|
|
//! `GetPetTrainingPoints`, `GetPetExperience` and `HasPetUI` (decision 1005; wow-re
|
|
//! `ui/scratch/pet-action-bar-api.md` §11b + §6).
|
|
//!
|
|
//! Its own module rather than more of [`crate::ui_pet`] because it is a different concern on a
|
|
//! different clock: the action bar's contents arrive whole in `SMSG_PET_SPELLS` and change when
|
|
//! the server says so, while these five read plain descriptor fields plus two DBC tables and move
|
|
//! continuously — happiness drifts every few seconds while a pet is out. Tying them together would
|
|
//! make the bar's diff-and-fire churn on numbers no button draws.
|
|
//!
|
|
//! **One gate governs all four stat bindings**: `0x6116e0(pet)`, which resolves the pet, requires
|
|
//! its `UNIT_FIELD_PETNUMBER`, requires its owner to be us, and finishes
|
|
//! `cmp byte [player.fields + 0x79], 3; sete al` — `UNIT_FIELD_BYTES_0` byte 1, the class, against
|
|
//! **Hunter**. (The enum is pinned by the combo-point gate's own class test on the same byte:
|
|
//! `== 4 || == 0xB` is Rogue and Druid, wow-re `combo-point-gate.md`.) A warlock's imp therefore
|
|
//! resolves perfectly and still answers nothing — happiness, loyalty and training points are
|
|
//! hunter machinery — and each binding says "nothing" in its own way ([`benilla_ui::script::PetStats`]).
|
|
//!
|
|
//! **Two more bindings ride here off the pet's `CreatureFamily.dbc` row** (decision 1062), and they
|
|
//! sit on **opposite sides of that gate** — which is the whole reason they are worth naming
|
|
//! together. `UnitCreatureFamily("pet")` (`0x51a310`) has no class test whatsoever, so a warlock's
|
|
//! imp shows "Imp" on the page's level line; `GetPetFoodTypes()` (`0x4bea10`) shares the very same
|
|
//! `0x6116e0` gate as the four stats above, so a minion — or a charmed beast under a non-hunter —
|
|
//! answers an empty diet even when its family row has a food mask. Both are one lookup on the same
|
|
//! clock as the block above, pushed by the same setter, which is why they live here rather than in
|
|
//! [`crate::ui_pet`]'s token feed: splitting them would mean two systems racing the same
|
|
//! [`crate::names::NameCache`] entry for the same answer. See [`family_for`] for the one thing that
|
|
//! is not obvious: a pet's creature-template entry is its *descriptor's*, never its guid's.
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{PetStats, ScriptValue, UiScript};
|
|
|
|
use crate::names::NameCache;
|
|
use crate::net::{NetCommands, ObjectStore};
|
|
use crate::ui_pet::{PetBar, PetUnit};
|
|
use crate::ui_script::UiInput;
|
|
use crate::ui_unit::UnitFeed;
|
|
|
|
/// `UNIT_FIELD_BYTES_0` byte 1 == 3 — **Hunter**, the class the four stat bindings gate on
|
|
/// (`0x611752`). Pinned rather than assumed: the same byte's `4`/`0xB` are Rogue and Druid in the
|
|
/// already-carved combo-point gate, which fixes the enum this value sits in.
|
|
const CLASS_HUNTER: u8 = 3;
|
|
|
|
/// `UNIT_FIELD_BYTES_0` byte 3 == 4 — the **happiness** power, the field `GetPetHappiness`
|
|
/// thresholds. Read by power *index*, not by the unit's active power type: a pet's displayed bar is
|
|
/// focus, and happiness is a parallel slot that is always there.
|
|
const POWER_HAPPINESS: u8 = 4;
|
|
|
|
/// The two pet DBC tables, loaded once at startup. Absent client data leaves the stat block empty,
|
|
/// which is the same shape as "not a hunter pet" — degraded, never wrong.
|
|
#[derive(Resource)]
|
|
pub(crate) struct PetStatTables {
|
|
pub(crate) personalities: benilla_formats::PetPersonalities,
|
|
pub(crate) loyalty: benilla_formats::PetLoyaltyNames,
|
|
}
|
|
|
|
/// The **family** pair — `CreatureFamily.dbc` (the word) and `ItemPetFood.dbc` (the diet the row's
|
|
/// mask names), decision 1062.
|
|
///
|
|
/// A separate resource from [`PetStatTables`] rather than four fields on it, so the two halves
|
|
/// degrade independently: a missing `ItemPetFood.dbc` must not take happiness down with it, and a
|
|
/// missing `PetPersonality.dbc` must not blank the level line.
|
|
#[derive(Resource)]
|
|
pub(crate) struct PetFamilyTables {
|
|
pub(crate) families: benilla_formats::CreatureFamilies,
|
|
pub(crate) foods: benilla_formats::PetFoodNames,
|
|
}
|
|
|
|
/// **The pet snapshot must be pushed before anything fires an event whose handlers read it**
|
|
/// (decision 1073). `fire_event` dispatches the Lua handlers *synchronously*, so a system that
|
|
/// fires while this frame's `set_pet_stats` is still pending hands the VM last frame's answer —
|
|
/// the codebase's own "push before firing" rule (`crate::ui_unit`), which held *inside* every
|
|
/// feed but not *between* them.
|
|
///
|
|
/// It cost the Pet tab. `PetTab_Update` is edge-driven off `UNIT_PET` / `PET_BAR_UPDATE`
|
|
/// and its whole predicate is `HasPetUI()`, which is this snapshot. With the three pet feeds
|
|
/// merely unordered inside [`UnitFeed`], a cold first summon fired both edges while `HasPetUI()`
|
|
/// still answered "no pet"; the push landed after, and since neither edge repeats, the tab stayed
|
|
/// down for the rest of the session. Measured live, frame by frame: both events on frame 440 with
|
|
/// `HasPetUI()=(nil,nil)`, the flip to `(1,nil)` on 441, and the tab still hidden 48 s later —
|
|
/// while calling `PetTab_Update()` by hand raised it instantly.
|
|
///
|
|
/// A set rather than a pair of `.before()` calls because the constraint belongs to the *snapshot*,
|
|
/// not to today's two consumers: anything that later fires a pet event inherits it by ordering
|
|
/// after this, and does not have to rediscover why.
|
|
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub(crate) struct PetSnapshot;
|
|
|
|
pub(crate) struct UiPetStatsPlugin;
|
|
|
|
impl Plugin for UiPetStatsPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
// Rides the unit feed beside the pet bar's own, and before the VM ticks — the pet frame
|
|
// repaints out of the same pass that pushes its health.
|
|
app.add_systems(
|
|
Update,
|
|
feed_pet_stats
|
|
.in_set(UnitFeed)
|
|
.in_set(PetSnapshot)
|
|
.before(UiInput),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The pet's family word and diet, resolved from its cached creature template (decision 1062).
|
|
///
|
|
/// **A pet's creature template entry is its descriptor's `OBJECT_FIELD_ENTRY`, never its guid.**
|
|
/// A `HIGHGUID_PET` guid carries a *pet number* in the entry-shaped slot (`crate::guid::pet_number`
|
|
/// — vmangos `Pet::Create` feeds `Object::_Create`'s entry parameter `petNumber`), which is exactly
|
|
/// why [`NameCache::resolve`] sends a pet-*name* query for one; but `Creature::InitEntry` still
|
|
/// writes the real template id into the descriptor (`Creature.cpp:376`, `SetEntry(entry) // normal
|
|
/// entry always`), so the template query has a key after all. That is the whole reason this reaches
|
|
/// past the guid: an Imp's descriptor says entry 416, whose template says `pet_family` 23, whose
|
|
/// `CreatureFamily.dbc` row says "Imp".
|
|
///
|
|
/// This is the **resolving** read — it issues the ask-once creature query on a miss — and it is the
|
|
/// only one for the pet, so the answer lands in the shared cache for anything else that wants it.
|
|
/// A miss returns the all-absent triple, which is the same shape as "this template has no family":
|
|
/// the binding's nil either way, and the query is in flight for the next frame.
|
|
///
|
|
/// The **icon** rides here rather than in a lookup of its own because it comes off the very same
|
|
/// family row as the word — `CreatureFamily.dbc`'s own icon column, which is a pet's only icon
|
|
/// (there is no item behind it). `GetPetIcon`'s answer, and the stable window's slot art
|
|
/// (decision 1676).
|
|
fn family_for(
|
|
pet: Option<&ObjectStore>,
|
|
names: &NameCache,
|
|
commands: &NetCommands,
|
|
tables: Option<&PetFamilyTables>,
|
|
) -> (Option<String>, Option<String>, Vec<String>) {
|
|
let Some(entry) = pet.and_then(|s| s.0.object_entry()).filter(|&e| e != 0) else {
|
|
return (None, None, Vec::new());
|
|
};
|
|
// The guid argument is the query body's second field and the server ignores it entirely
|
|
// (vmangos `HandleCreatureQueryOpcode` answers off `packet.entry` alone), so the template-only
|
|
// `0` convention `Items::template` already uses applies here too.
|
|
let _ = names.resolve_creature(entry, 0, commands);
|
|
let Some(tables) = tables else {
|
|
return (None, None, Vec::new());
|
|
};
|
|
let Some(family_id) = names.creature_record(entry).map(|r| r.pet_family) else {
|
|
return (None, None, Vec::new());
|
|
};
|
|
let Some(family) = tables.families.get(family_id) else {
|
|
return (None, None, Vec::new());
|
|
};
|
|
(
|
|
Some(family.name.clone()),
|
|
tables.families.icon(family_id).map(str::to_string),
|
|
tables
|
|
.foods
|
|
.for_mask(family.pet_food_mask)
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// Resolve the whole stat block for the current pet, or [`PetStats::default`] when there is none.
|
|
///
|
|
/// Split from the system so the composition is testable: every one of the five *stat* values passes
|
|
/// through the *same* `hunter_pet` gate, and a bug that let one leak past it would show up as a
|
|
/// warlock with a loyalty level rather than as a compile error.
|
|
///
|
|
/// `family` ([`family_for`]'s pair) is threaded in already resolved, and the two halves land on
|
|
/// **opposite sides of the hunter gate** — the word survives it (`UnitCreatureFamily` has no class
|
|
/// test), the diet does not (`GetPetFoodTypes` shares `0x6116e0`). See the gate's own comment.
|
|
fn stats_for(
|
|
pet: Option<&ObjectStore>,
|
|
self_store: Option<&ObjectStore>,
|
|
tables: Option<&PetStatTables>,
|
|
family: (Option<String>, Option<String>, Vec<String>),
|
|
) -> (bool, PetStats) {
|
|
let (family, icon, food_types) = family;
|
|
let Some(fields) = pet.map(|s| &s.0) else {
|
|
return (false, PetStats::default());
|
|
};
|
|
// `HasPetUI`'s FIRST return (`0x4be697`): a pet that resolves AND carries a pet number. Not the
|
|
// action bar's gate, which is the cached guid alone — a possessed creature has a bar and no
|
|
// pet number, so it gets buttons and no paper doll.
|
|
let has_ui = fields.unit_is_pet_or_charm();
|
|
// …and the second: the owner's class. We only ever hold a bar for a pet the server named us
|
|
// the controller of, so the owner leg is structurally true here; the class is the live test.
|
|
let hunter = has_ui
|
|
&& self_store.map(|s| ((s.0.unit_bytes_0().unwrap_or(0) >> 8) & 0xff) as u8)
|
|
== Some(CLASS_HUNTER);
|
|
if !hunter {
|
|
// **The family WORD rides past the gate; the DIET does not** — and the split is carved,
|
|
// not chosen. `UnitCreatureFamily 0x51a310` has no class test at all (its only nil paths
|
|
// are "no record / id 0 / out of range / a null row"), so a warlock's minion shows "Imp"
|
|
// on the page's level line. `GetPetFoodTypes 0x4bea10` is gated on `0x6116e0(pet)` — the
|
|
// same owner-is-me + class-is-Hunter gate as the four stat bindings — so it answers
|
|
// *nothing* for a minion even though the word is there. The shipped data hides the
|
|
// difference for warlocks (every minion family ships food mask 0), but not for a
|
|
// **charmed beast under a non-hunter**: a mind-controlled boar has family 5 and mask 63,
|
|
// and the reference still answers an empty diet for it. (wow-re, 2026-08-06.)
|
|
return (
|
|
has_ui,
|
|
PetStats {
|
|
family,
|
|
// The icon rides past the gate with the WORD, not with the diet — same family
|
|
// row, same reasoning (decision 1676; the placement is INFERRED, see `PetStats`).
|
|
icon,
|
|
..PetStats::default()
|
|
},
|
|
);
|
|
}
|
|
let (happiness, damage_percentage, loyalty_rate) = tables
|
|
.and_then(|t| t.personalities.for_pet(None))
|
|
.map(|p| {
|
|
let h = p.happiness(fields.unit_power(POWER_HAPPINESS).unwrap_or(0));
|
|
(Some(h.bucket), h.damage_percentage, h.loyalty_rate)
|
|
})
|
|
// No DBC ⇒ the binding's own gate-failure numbers, and nil for the bucket.
|
|
.unwrap_or((None, 100.0, 0.0));
|
|
(
|
|
has_ui,
|
|
PetStats {
|
|
hunter_pet: true,
|
|
happiness,
|
|
damage_percentage,
|
|
loyalty_rate,
|
|
loyalty: tables.and_then(|t| {
|
|
t.loyalty
|
|
.name(u32::from(fields.unit_loyalty_level()))
|
|
.map(str::to_string)
|
|
}),
|
|
training_points: fields.unit_training_points(),
|
|
experience: fields.unit_pet_experience(),
|
|
family,
|
|
icon,
|
|
food_types,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn feed_pet_stats(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
bar: Res<PetBar>,
|
|
pet: PetUnit,
|
|
self_store: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
|
|
tables: Option<Res<PetStatTables>>,
|
|
family_tables: Option<Res<PetFamilyTables>>,
|
|
names: Res<NameCache>,
|
|
commands: Res<NetCommands>,
|
|
mut last: Local<crate::ui_script::VmMemo<Option<(bool, PetStats)>>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let last = last.get(&script);
|
|
let store = pet.store(bar.spells.pet_guid);
|
|
let family = family_for(store, &names, &commands, family_tables.as_deref());
|
|
let fresh = stats_for(store, self_store.iter().next(), tables.as_deref(), family);
|
|
// Push only on change. Happiness moves on its own clock and the frame repaints off UNIT_*
|
|
// events, so a per-frame push would be pure churn — but the DIFF is what makes this cheap,
|
|
// not a timer, so a real change still lands the same frame it arrives.
|
|
if last.as_ref() == Some(&fresh) {
|
|
return;
|
|
}
|
|
// `UNIT_HAPPINESS` — the pet frame's repaint wire for the icon (ref `PetFrame_OnEvent`'s last
|
|
// arm). The reference fires it off the happiness FIELD changing; we fire it off the three
|
|
// values the icon and its tooltip are built from, which is the same event for every consumer
|
|
// there is — the icon is bucketed and both tooltip lines are bucket-derived, so movement
|
|
// *within* a bucket has nothing to repaint. Named because it is a narrower trigger, not a
|
|
// wider one: nothing reads the raw happiness number.
|
|
let happiness_moved = last.as_ref().map(|(_, s)| {
|
|
(
|
|
s.happiness,
|
|
s.damage_percentage.to_bits(),
|
|
s.loyalty_rate.to_bits(),
|
|
)
|
|
}) != Some((
|
|
fresh.1.happiness,
|
|
fresh.1.damage_percentage.to_bits(),
|
|
fresh.1.loyalty_rate.to_bits(),
|
|
));
|
|
// The pet page's other two repaint wires (ref `PetPaperDollFrame.lua:9,21`), cut the same
|
|
// narrow way: `UNIT_PET_EXPERIENCE` is the XP bar's ONLY wire (`PetExpBar_Update`, l.43-44 —
|
|
// the one event arm that repaints nothing else), and `UNIT_PET_TRAINING_POINTS` reaches only
|
|
// `PetPaperDollFrame_Update`'s training-point text. Firing each off its own pair rather than
|
|
// off the whole block keeps happiness drift — which moves every few seconds while a pet is
|
|
// out — from repainting two numbers that did not change.
|
|
let xp_moved = last.as_ref().map(|(_, s)| s.experience) != Some(fresh.1.experience);
|
|
let training_moved =
|
|
last.as_ref().map(|(_, s)| s.training_points) != Some(fresh.1.training_points);
|
|
if happiness_moved {
|
|
debug!(
|
|
"ui_pet_stats: happiness {:?} ({}% damage), loyalty {:?}",
|
|
fresh.1.happiness, fresh.1.damage_percentage, fresh.1.loyalty
|
|
);
|
|
}
|
|
// **The family lands LATE and fires NOTHING — a named hole, not an oversight** (decision
|
|
// 1062). It arrives with the creature-query answer, a round trip after the pet streams, and
|
|
// no event the pet page registers is guaranteed to fire then: `fire_transitions` has no arm
|
|
// for it, `UNIT_CLASSIFICATION_CHANGED` (the reference's own "a creature query landed" wire,
|
|
// decision 0782) is neither registered by the page nor even edge-able for a pet, whose gated
|
|
// rank is pinned at 0. Inventing a fire site — `PET_UI_UPDATE`, or `UNIT_LEVEL` because the
|
|
// family shares the level's line — would be asserting a mechanism nobody has carved.
|
|
//
|
|
// What makes it *survivable* rather than broken: the query goes out the frame the pet's
|
|
// descriptor arrives, thousands of frames before a human can open the character window, so
|
|
// the page's own `OnShow` → `_Update` covers every ordinary open. The one reachable blank is
|
|
// a page already sitting open at the instant a pet is summoned, which the next stat event
|
|
// (`UNIT_STATS`/`UNIT_DAMAGE`/…, all of which the page registers and all of which fire
|
|
// repeatedly while a pet's descriptor streams) repaints within a frame or two. This line is
|
|
// the instrument for it: a family that never appears names itself here.
|
|
if last.as_ref().map(|(_, s)| &s.family) != Some(&fresh.1.family) {
|
|
debug!(
|
|
"ui_pet_stats: pet family {:?}, diet {:?}",
|
|
fresh.1.family, fresh.1.food_types
|
|
);
|
|
}
|
|
*last = Some(fresh.clone());
|
|
script.set_pet_stats(fresh.0, fresh.1);
|
|
// Push before firing — dispatch runs the Lua handlers synchronously (the `ui_unit` rule).
|
|
if happiness_moved {
|
|
// `%s` — the unit token, per the reference's own fire site (SignalEvent2, decision 1884).
|
|
// Every 1.12 `UNIT_*` event carries it, and its consumers gate on it: a handler's first
|
|
// line is `if ( arg1 == this.unit )`, so an argless fire reaches nobody.
|
|
script.fire_event("UNIT_HAPPINESS", vec![ScriptValue::Str("pet".into())]);
|
|
}
|
|
// Both of these are the SAME bridge as `UNIT_HAPPINESS` above — named unit-descriptor fields
|
|
// (141/142 `PET_EXPERIENCE`/`PET_NEXT_LEVEL_EXP`, 149 `TRAINING_POINTS`), whose events the
|
|
// reference dispatches through `0x515e50`'s token fan-out, `SignalEvent2(id, "%s", token)`.
|
|
// They were argless here for months, two lines under a comment stating the law, and the cost
|
|
// was not theoretical: `PetPaperDollFrame_OnEvent` gives `UNIT_PET_EXPERIENCE` a named branch
|
|
// but routes `UNIT_PET_TRAINING_POINTS` to its final `elseif ( arg1 == "pet" )` catch-all
|
|
// (`PetPaperDollFrame.lua:44`), so an argless fire reached nobody and the pet page's training
|
|
// points never repainted off the event at all.
|
|
if xp_moved {
|
|
script.fire_event("UNIT_PET_EXPERIENCE", vec![ScriptValue::Str("pet".into())]);
|
|
}
|
|
if training_moved {
|
|
script.fire_event(
|
|
"UNIT_PET_TRAINING_POINTS",
|
|
vec![ScriptValue::Str("pet".into())],
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::ObjectFields;
|
|
|
|
const BYTES_0: u16 = 36;
|
|
const BYTES_1: u16 = 138;
|
|
const POWER5: u16 = 27;
|
|
const PETNUMBER: u16 = 139;
|
|
const PETXP: u16 = 141;
|
|
const PETNEXTXP: u16 = 142;
|
|
const TRAINING: u16 = 149;
|
|
/// `OBJECT_FIELD_ENTRY` — the pet's creature-template id ([`family_for`]'s whole key).
|
|
const ENTRY: u16 = 3;
|
|
/// Real `creature_template` entries on the live VM, so the family ids below are the ones the
|
|
/// server would actually send: Imp → `pet_family` 23, Stonetusk Boar → 5.
|
|
const IMP_ENTRY: u32 = 416;
|
|
const BOAR_ENTRY: u32 = 113;
|
|
|
|
fn hunter() -> ObjectStore {
|
|
// BYTES_0 byte 1 = class. Hunter = 3.
|
|
ObjectStore(ObjectFields::from_pairs(&[(
|
|
BYTES_0,
|
|
u32::from(CLASS_HUNTER) << 8,
|
|
)]))
|
|
}
|
|
|
|
fn warlock() -> ObjectStore {
|
|
ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, 9 << 8)]))
|
|
}
|
|
|
|
/// A boar: pet number set, loyalty level 6, happy, part-trained, mid-XP, and its real
|
|
/// `creature_template` entry in `OBJECT_FIELD_ENTRY`.
|
|
fn boar() -> ObjectStore {
|
|
ObjectStore(ObjectFields::from_pairs(&[
|
|
(ENTRY, BOAR_ENTRY),
|
|
(PETNUMBER, 42),
|
|
(BYTES_1, 6 << 8), // loyalty level in byte 1
|
|
(POWER5, 1_000_000), // maximum happiness
|
|
(TRAINING, (170 << 16) | 130), // total in the HIGH word, spent in the low
|
|
(PETXP, 4200),
|
|
(PETNEXTXP, 8000),
|
|
]))
|
|
}
|
|
|
|
fn chain() -> Option<benilla_formats::Chain> {
|
|
let data = benilla_formats::wow_data_or_skip!(None);
|
|
Some(benilla_formats::open_chain(&data).expect("open chain"))
|
|
}
|
|
|
|
fn tables() -> Option<PetStatTables> {
|
|
let mut chain = chain()?;
|
|
Some(PetStatTables {
|
|
personalities: benilla_formats::load_pet_personalities(&mut chain)
|
|
.expect("personality"),
|
|
loyalty: benilla_formats::load_pet_loyalty_names(&mut chain).expect("loyalty"),
|
|
})
|
|
}
|
|
|
|
fn family_tables() -> Option<PetFamilyTables> {
|
|
let mut chain = chain()?;
|
|
Some(PetFamilyTables {
|
|
families: benilla_formats::load_creature_families(&mut chain).expect("families"),
|
|
foods: benilla_formats::load_pet_food_names(&mut chain).expect("foods"),
|
|
})
|
|
}
|
|
|
|
/// The "no family resolved" pair, for the stat tests that are not about the family.
|
|
fn no_family() -> (Option<String>, Option<String>, Vec<String>) {
|
|
(None, None, Vec::new())
|
|
}
|
|
|
|
/// A `NameCache` with `entry`'s creature record already cached, carrying `pet_family` —
|
|
/// i.e. the state after `SMSG_CREATURE_QUERY_RESPONSE` has landed.
|
|
fn cache_with(entry: u32, pet_family: u32) -> NameCache {
|
|
let mut names = NameCache::default();
|
|
names.insert_creature(
|
|
entry,
|
|
Some(crate::names::CreatureRecord {
|
|
name: "Snarl".into(),
|
|
subname: None,
|
|
creature_type: 1,
|
|
pet_family,
|
|
rank: 0,
|
|
type_flags: 0,
|
|
civilian: false,
|
|
racial_leader: false,
|
|
display_id: 0,
|
|
}),
|
|
);
|
|
names
|
|
}
|
|
|
|
fn commands() -> (
|
|
NetCommands,
|
|
crossbeam_channel::Receiver<crate::net::ClientCommand>,
|
|
) {
|
|
let (tx, rx) = crossbeam_channel::unbounded();
|
|
(NetCommands(tx), rx)
|
|
}
|
|
|
|
/// The whole block end to end on the real DBC data — the packed/derived reads are the point:
|
|
/// the training-points word order, the loyalty byte, and happiness coming out PRE-BUCKETED
|
|
/// with vanilla's own 125% damage for a maxed pet.
|
|
#[test]
|
|
fn a_hunters_boar_reads_every_field() {
|
|
let Some(t) = tables() else { return };
|
|
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), Some(&t), no_family());
|
|
assert!(has_ui && s.hunter_pet);
|
|
assert_eq!(s.happiness, Some(3));
|
|
assert_eq!(s.damage_percentage, 125.0);
|
|
assert_eq!(s.loyalty_rate, 20.0);
|
|
assert_eq!(s.loyalty.as_deref(), Some("(Loyalty Level 6) Best Friend"));
|
|
assert_eq!(
|
|
s.training_points,
|
|
(170, 130),
|
|
"TOTAL first, from the high word"
|
|
);
|
|
assert_eq!(s.experience, (4200, 8000));
|
|
}
|
|
|
|
/// The happiness bucket really is derived from the power field, not passed through: three
|
|
/// different raw values give three different buckets and three different damage numbers.
|
|
#[test]
|
|
fn happiness_buckets_the_raw_power() {
|
|
let Some(t) = tables() else { return };
|
|
let at = |raw: u32| {
|
|
let store = ObjectStore(ObjectFields::from_pairs(&[
|
|
(PETNUMBER, 42),
|
|
(BYTES_1, 0),
|
|
(POWER5, raw),
|
|
]));
|
|
let (_, s) = stats_for(Some(&store), Some(&hunter()), Some(&t), no_family());
|
|
(s.happiness, s.damage_percentage)
|
|
};
|
|
assert_eq!(at(0), (Some(1), 75.0), "an unhappy pet deals 75%");
|
|
assert_eq!(at(500_000), (Some(2), 100.0));
|
|
assert_eq!(at(1_000_000), (Some(3), 125.0));
|
|
}
|
|
|
|
/// **The gate is the class, and it takes everything with it.** A warlock's imp has a pet
|
|
/// number — so `HasPetUI`'s first return stays true — and answers no stats at all. Letting one
|
|
/// value leak past the gate is the failure this guards: an imp with a loyalty level.
|
|
#[test]
|
|
fn a_warlocks_minion_has_a_ui_and_no_stats() {
|
|
let Some(t) = tables() else { return };
|
|
let (has_ui, s) = stats_for(Some(&boar()), Some(&warlock()), Some(&t), no_family());
|
|
assert!(
|
|
has_ui,
|
|
"the pet number is what HasPetUI's first return reads"
|
|
);
|
|
assert!(!s.hunter_pet);
|
|
assert_eq!(s.happiness, None);
|
|
assert_eq!(s.loyalty, None);
|
|
assert_eq!(s.training_points, (0, 0));
|
|
assert_eq!(s.experience, (0, 0));
|
|
}
|
|
|
|
/// A possessed creature has an action bar but no pet number, so it gets no paper doll either —
|
|
/// the two gates are genuinely different (`PetHasActionBar` is the cached guid alone).
|
|
#[test]
|
|
fn no_pet_number_means_no_pet_ui() {
|
|
let Some(t) = tables() else { return };
|
|
let possessed = ObjectStore(ObjectFields::from_pairs(&[(POWER5, 1_000_000)]));
|
|
let (has_ui, s) = stats_for(Some(&possessed), Some(&hunter()), Some(&t), no_family());
|
|
assert!(!has_ui);
|
|
assert!(!s.hunter_pet);
|
|
}
|
|
|
|
/// Loyalty level 0 is nil, not level 1 — the client's own bound, and the difference between a
|
|
/// fresh pet showing nothing and showing "Rebellious".
|
|
#[test]
|
|
fn loyalty_level_zero_is_nil() {
|
|
let Some(t) = tables() else { return };
|
|
let fresh = ObjectStore(ObjectFields::from_pairs(&[
|
|
(PETNUMBER, 42),
|
|
(BYTES_1, 0),
|
|
(POWER5, 1_000_000),
|
|
]));
|
|
let (_, s) = stats_for(Some(&fresh), Some(&hunter()), Some(&t), no_family());
|
|
assert_eq!(s.loyalty, None);
|
|
assert_eq!(s.happiness, Some(3), "…but happiness still answers");
|
|
}
|
|
|
|
/// Missing client data degrades to the bindings' own gate-failure numbers rather than to a
|
|
/// wrong bucket: nil happiness with `(100.0, 0.0)` beside it.
|
|
#[test]
|
|
fn absent_dbc_data_degrades_to_the_failure_numbers() {
|
|
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), None, no_family());
|
|
assert!(has_ui && s.hunter_pet);
|
|
assert_eq!(s.happiness, None);
|
|
assert_eq!((s.damage_percentage, s.loyalty_rate), (100.0, 0.0));
|
|
// The descriptor-only values still read — they need no table.
|
|
assert_eq!(s.training_points, (170, 130));
|
|
}
|
|
|
|
/// The RUNTIME leg on the real data: every GlobalStrings key the happiness tooltip resolves
|
|
/// exists in the shipped 1.12 file, with the `%d` the damage line formats into. A typo'd key
|
|
/// here is silent — `getglobal` answers nil and the tooltip simply loses a line.
|
|
#[test]
|
|
fn every_happiness_string_resolves_in_the_real_global_strings() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let src = chain
|
|
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
|
.expect("GlobalStrings.lua in the chain");
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
|
let g = |key: &str| {
|
|
s.lua()
|
|
.globals()
|
|
.get::<String>(key)
|
|
.ok()
|
|
.unwrap_or_default()
|
|
};
|
|
|
|
// The three bucket names are resolved by CONCATENATION (`"PET_HAPPINESS"..happiness`), so
|
|
// a missing one shows as an empty tooltip for that bucket alone — exactly the failure a
|
|
// by-eye check misses.
|
|
assert_eq!(g("PET_HAPPINESS1"), "Unhappy");
|
|
assert_eq!(g("PET_HAPPINESS2"), "Content");
|
|
assert_eq!(g("PET_HAPPINESS3"), "Happy");
|
|
assert!(
|
|
g("PET_DAMAGE_PERCENTAGE").contains("%d"),
|
|
"the damage line formats the percentage in: {:?}",
|
|
g("PET_DAMAGE_PERCENTAGE")
|
|
);
|
|
assert!(!g("LOSING_LOYALTY").is_empty());
|
|
assert!(!g("GAINING_LOYALTY").is_empty());
|
|
}
|
|
|
|
/// No pet at all: no UI, no stats, and nothing that could be mistaken for a real answer.
|
|
#[test]
|
|
fn no_pet_is_no_ui() {
|
|
let (has_ui, s) = stats_for(None, Some(&hunter()), None, no_family());
|
|
assert!(!has_ui);
|
|
assert_eq!(s, PetStats::default());
|
|
}
|
|
|
|
/// **The family lookup end to end on the real DBC data** (decision 1062), including all three
|
|
/// nil sources the binding has to reproduce.
|
|
///
|
|
/// The load-bearing fact under test is the KEY: the pet's template entry is read from
|
|
/// `OBJECT_FIELD_ENTRY`, not from its guid (whose entry-shaped slot holds a pet number). A
|
|
/// version that asked the guid would query a nonexistent template and answer nil forever —
|
|
/// silently, since nil is a legitimate answer here.
|
|
#[test]
|
|
fn the_pets_family_resolves_off_its_descriptor_entry() {
|
|
let Some(t) = family_tables() else { return };
|
|
let (cmds, rx) = commands();
|
|
|
|
// 1. No pet at all.
|
|
let names = NameCache::default();
|
|
assert_eq!(family_for(None, &names, &cmds, Some(&t)), no_family());
|
|
assert!(rx.try_recv().is_err(), "nothing to ask about");
|
|
|
|
// 2. A pet whose creature query has NOT answered yet — nil, and the ask goes out (once).
|
|
let pet = ObjectStore(ObjectFields::from_pairs(&[
|
|
(ENTRY, IMP_ENTRY),
|
|
(PETNUMBER, 7),
|
|
]));
|
|
assert_eq!(
|
|
family_for(Some(&pet), &names, &cmds, Some(&t)),
|
|
no_family(),
|
|
"un-queried is nil, not a guess"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
rx.try_recv(),
|
|
Ok(crate::net::ClientCommand::CreatureQuery { entry, .. }) if entry == IMP_ENTRY
|
|
),
|
|
"the pet's DESCRIPTOR entry is what gets queried"
|
|
);
|
|
assert!(rx.try_recv().is_err(), "ask-once");
|
|
|
|
// 3. The answer lands with family 0 — a template with no family. Still nil, and this is
|
|
// the common case for every non-tameable creature.
|
|
let names = cache_with(IMP_ENTRY, 0);
|
|
assert_eq!(family_for(Some(&pet), &names, &cmds, Some(&t)), no_family());
|
|
|
|
// 4. The answer lands with the Imp's real family (23, from the live `creature_template`).
|
|
// A warlock minion: a word, and an EMPTY diet — mask 0 in the shipped DBC.
|
|
let names = cache_with(IMP_ENTRY, 23);
|
|
assert_eq!(
|
|
family_for(Some(&pet), &names, &cmds, Some(&t)),
|
|
(
|
|
Some("Imp".into()),
|
|
// The family row's own icon column (decision 1676) — and the shipped value for a
|
|
// warlock minion is **`Ability_Druid_CatForm`**, not any imp art. That is not a
|
|
// misread: rows 15 (Felhunter) and 23 (Imp) both carry it in the real 5875 file,
|
|
// where every one of the 22 hunter families carries its correct
|
|
// `Ability_Hunter_Pet_<Family>`. It is placeholder data Blizzard never filled in,
|
|
// and nothing in the reference can show it — the stable is the column's only
|
|
// consumer and it early-returns for warlocks before reading one. Asserted as
|
|
// shipped rather than as expected.
|
|
Some("Interface\\Icons\\Ability_Druid_CatForm".into()),
|
|
Vec::new()
|
|
)
|
|
);
|
|
|
|
// 5. A hunter's boar (entry 113 → family 5): a word AND the six-diet list, in bit order.
|
|
let names = cache_with(BOAR_ENTRY, 5);
|
|
let (name, icon, diet) = family_for(Some(&boar()), &names, &cmds, Some(&t));
|
|
assert_eq!(name.as_deref(), Some("Boar"));
|
|
assert_eq!(
|
|
icon.as_deref(),
|
|
Some("Interface\\Icons\\Ability_Hunter_Pet_Boar")
|
|
);
|
|
assert_eq!(diet, ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]);
|
|
|
|
// 6. No DBC tables at all: nil, degraded to exactly the blank level line 1057 shipped.
|
|
let names = cache_with(BOAR_ENTRY, 5);
|
|
assert_eq!(family_for(Some(&boar()), &names, &cmds, None), no_family());
|
|
}
|
|
|
|
/// **The family WORD survives the hunter gate; the DIET does not** — the carved split
|
|
/// (wow-re, 2026-08-06: `UnitCreatureFamily 0x51a310` has no class test, `GetPetFoodTypes
|
|
/// 0x4bea10` shares `0x6116e0` with the four stats).
|
|
///
|
|
/// The pet here is deliberately a **boar under a non-hunter** — a charmed beast, family 5,
|
|
/// food mask 63 — because that is the one case where the gate is observable at all: a warlock
|
|
/// minion's family ships mask 0, so an ungated implementation would look identical for it and
|
|
/// diverge only here. Folding the family word into the gate would blank a level line the
|
|
/// reference fills; leaving the diet out of it would feed a priest's mind-controlled boar a
|
|
/// six-item menu the reference never shows.
|
|
#[test]
|
|
fn a_charmed_beast_keeps_its_family_word_and_loses_its_diet() {
|
|
let Some(t) = tables() else { return };
|
|
let boar_diet: Vec<String> = ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]
|
|
.map(String::from)
|
|
.to_vec();
|
|
let (_, s) = stats_for(
|
|
Some(&boar()),
|
|
Some(&warlock()),
|
|
Some(&t),
|
|
(Some("Boar".into()), None, boar_diet.clone()),
|
|
);
|
|
assert!(!s.hunter_pet);
|
|
assert_eq!(s.family.as_deref(), Some("Boar"), "the word is ungated");
|
|
assert!(
|
|
s.food_types.is_empty(),
|
|
"…but the diet shares 0x6116e0 with the stats"
|
|
);
|
|
assert_eq!(s.loyalty, None, "…as does the hunter machinery");
|
|
assert_eq!(s.happiness, None);
|
|
|
|
// The same pet under a HUNTER gets both.
|
|
let (_, s) = stats_for(
|
|
Some(&boar()),
|
|
Some(&hunter()),
|
|
Some(&t),
|
|
(Some("Boar".into()), None, boar_diet.clone()),
|
|
);
|
|
assert_eq!(s.family.as_deref(), Some("Boar"));
|
|
assert_eq!(s.food_types, boar_diet);
|
|
}
|
|
|
|
/// …and with no pet, the family goes with everything else — a stale word left on the block
|
|
/// would outlive the pet on a page that is about to close.
|
|
#[test]
|
|
fn no_pet_drops_the_family_too() {
|
|
let (_, s) = stats_for(
|
|
None,
|
|
Some(&hunter()),
|
|
None,
|
|
(Some("Imp".into()), None, vec![]),
|
|
);
|
|
assert_eq!(s.family, None);
|
|
assert!(s.food_types.is_empty());
|
|
}
|
|
|
|
/// **The schedule invariant, not the function's** (decision 1073): by the time `UNIT_PET`
|
|
/// reaches a Lua handler, `HasPetUI()` must already answer for the pet that event announces.
|
|
///
|
|
/// This is the Pet-tab bug in its smallest reproducible form, and it is only visible from a
|
|
/// real `App`: every unit test above calls `stats_for` directly and passes whatever the
|
|
/// ordering does. Driven through **one** `app.update()` on a cold pet — the exact live shape,
|
|
/// where `feed_pet_unit` sees the guid appear and fires while `feed_pet_stats` has or has not
|
|
/// yet pushed. A handler that reads `HasPetUI()` at that instant is what the shipped
|
|
/// `PetTab_Update` is, minus the frames.
|
|
#[test]
|
|
fn unit_pet_reaches_lua_with_has_pet_ui_already_true() {
|
|
use crate::char_select::ClientState;
|
|
use crate::net::GuidIndex;
|
|
use crate::ui_pet::UiPetPlugin;
|
|
|
|
const PET_GUID: u64 = 0xf140_0000_0000_002a;
|
|
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
|
|
.insert_state(ClientState::InWorld)
|
|
.init_resource::<NameCache>()
|
|
.init_resource::<GuidIndex>()
|
|
.init_resource::<crate::target::Selection>()
|
|
.init_resource::<crate::net::SelfGuid>()
|
|
.init_resource::<crate::ui_script::UiClock>()
|
|
.init_resource::<crate::ui_action::UiErrorKeys>()
|
|
.init_resource::<crate::net::Reputations>()
|
|
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
|
|
.init_resource::<crate::ui_action::AutoRepeatActive>()
|
|
.add_message::<crate::creature_anim::SheathRequest>()
|
|
.add_message::<crate::net::FieldChanged>()
|
|
.insert_resource(NetCommands(tx))
|
|
.add_plugins((UiPetStatsPlugin, UiPetPlugin));
|
|
|
|
// The pet is already in the world when the bar's guid lands — the cold-summon shape.
|
|
let pet = app.world_mut().spawn(boar()).id();
|
|
app.world_mut()
|
|
.resource_mut::<GuidIndex>()
|
|
.0
|
|
.insert(PET_GUID, pet);
|
|
app.world_mut().resource_mut::<PetBar>().spells.pet_guid = PET_GUID;
|
|
|
|
// A bare frame standing in for the pet page: it records what `HasPetUI()` answered at the
|
|
// moment the event was dispatched, which is the only thing under test.
|
|
let script = UiScript::new().unwrap();
|
|
script
|
|
.run(
|
|
r#"
|
|
BENILLA_SAW = "never fired"
|
|
local f = CreateFrame("Frame")
|
|
f:RegisterEvent("UNIT_PET")
|
|
f:SetScript("OnEvent", function()
|
|
BENILLA_SAW = HasPetUI() and "has pet UI" or "no pet UI"
|
|
end)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
app.insert_non_send_resource(script);
|
|
|
|
app.update();
|
|
|
|
let saw: String = app
|
|
.world_mut()
|
|
.non_send_resource::<UiScript>()
|
|
.eval("return BENILLA_SAW")
|
|
.unwrap();
|
|
assert_eq!(
|
|
saw, "has pet UI",
|
|
"UNIT_PET must not reach Lua ahead of the snapshot its handlers read — unordered, \
|
|
this answered \"no pet UI\" and the Pet tab stayed down for the session"
|
|
);
|
|
}
|
|
}
|