loot and pet refusals become message ids, and waterfalls flow again

- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:39:11 +07:00
co-authored by Claude Fable 5.1
parent 4040166138
commit 8f0bece3be
41 changed files with 3488 additions and 547 deletions
+22
View File
@@ -117,6 +117,8 @@ impl Plugin for NetPlugin {
.add_message::<ServerSoundMessage>()
.add_message::<EmoteMessage>()
.add_message::<AiReactionMessage>()
.add_message::<PetTalkMessage>()
.add_message::<PetDismissSoundMessage>()
.add_message::<WorldportMessage>()
.add_message::<CharListMessage>()
.add_message::<CharActionResultMessage>()
@@ -2632,6 +2634,26 @@ pub(crate) struct AiReactionMessage {
pub(crate) hostile: bool,
}
/// The pet spoke (`SMSG_PET_ACTION_SOUND`, bridged from the Net drain; decision 2039): `talk` is
/// the wire's own selector (`PET_TALK_ORDER` / `PET_TALK_ATTACK`), not a `SoundEntries` id — the
/// kit comes off the pet's own `CreatureSoundData` row. Pure audio in the client (`0x6040c0`
/// resolves the guid and calls the bark dispatcher, nothing else); consumer: `sound::creature`.
#[derive(Message, Clone, Copy)]
pub(crate) struct PetTalkMessage {
pub(crate) unit: Entity,
pub(crate) talk: u32,
}
/// A dismissed pet's parting sound (`SMSG_PET_DISMISS_SOUND`, decision 2039): a
/// `CreatureModelData` id and the point, already in Bevy space. **No entity** — the packet names
/// no guid and the pet is gone by the time it arrives, which is the whole reason it carries a
/// position at all. Consumer: `sound::creature`.
#[derive(Message, Clone, Copy)]
pub(crate) struct PetDismissSoundMessage {
pub(crate) model_id: u32,
pub(crate) pos: Vec3,
}
#[cfg(test)]
mod tests {
use super::*;
+65 -42
View File
@@ -11,8 +11,9 @@ use bevy::prelude::*;
use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, ServerWallClock, TeleportMessage, UnitMoveModes, WorldportMessage,
PendingTransfer, PetDismissSoundMessage, PetTalkMessage, RemoteMotion, Reputations, SelfGuid,
SelfPlayer, ServerSoundMessage, ServerTime, ServerWallClock, TeleportMessage, UnitMoveModes,
WorldportMessage,
};
use benilla_world::weather::WeatherMessage;
@@ -327,10 +328,13 @@ pub(crate) fn apply_net_updates(
// drain sends it through the one cast path. Rides here for the same reason the
// catalog does: this is where the ceiling left room.
ResMut<crate::ui_action::ChainCasts>,
// The taming-refusal queue (decision 2039) — `SMSG_PET_TAME_FAILURE`'s reason byte.
// Its own queue rather than `UiErrorKeys` because its message needs TWO GlobalStrings
// lookups, and the inner one is only reachable at the drain (see the resource's doc).
ResMut<crate::ui_action::PetTameFailures>,
),
ResMut<crate::ui_items::EquipErrors>,
ResMut<crate::ui_merchant::MerchantErrors>,
ResMut<crate::ui_loot::LootErrors>,
ResMut<crate::ui_cast::CastBarFeed>,
ResMut<crate::pending_item_ops::PendingItemOps>,
ResMut<crate::pending_item_ops::LockTransitions>,
@@ -398,6 +402,12 @@ pub(crate) fn apply_net_updates(
// auto-attack start (`0x6e83e7`, decision 1593). Filter-only, so it conflicts with
// nothing else in this drain.
Query<(), (With<crate::creature_anim::Engaged>, With<SelfPlayer>)>,
// The pet's bark (`SMSG_PET_ACTION_SOUND`, decision 2039) — `AiReactionMessage`'s
// sibling: both are pure audio that resolves a guid and hands the sound layer a
// state for the SAME `0x623a40` dispatcher. Nested for the same reason as its
// neighbours: the outer tuple is at the 16-param ceiling.
MessageWriter<PetTalkMessage>,
MessageWriter<PetDismissSoundMessage>,
),
),
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
@@ -550,7 +560,7 @@ pub(crate) fn apply_net_updates(
index: &index,
factions: ui_actions.1 .2.as_deref(),
reputations: &reputations,
spells: ui_actions.11.as_deref(),
spells: ui_actions.10.as_deref(),
}
};
}
@@ -1089,7 +1099,7 @@ pub(crate) fn apply_net_updates(
SessionEvent::SpellBook {
spell_ids,
cooldowns,
} => spell_book(spell_ids, cooldowns, &mut ui_actions.0, &mut ui_actions.10),
} => spell_book(spell_ids, cooldowns, &mut ui_actions.0, &mut ui_actions.9),
SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut ui_actions.0),
SessionEvent::SpellLearned { spell_id } => learned_spell(spell_id, &mut ui_actions.0),
SessionEvent::SpellRemoved { spell_id } => removed_spell(spell_id, &mut ui_actions.0),
@@ -1113,12 +1123,12 @@ pub(crate) fn apply_net_updates(
&mut ui_actions.1 .0,
&audio.4,
&mut audio.5,
&mut ui_actions.5,
&mut ui_actions.4,
&mut ui_actions.8,
&mut ui_actions.14,
&mut ui_actions.9,
&mut ui_actions.15,
&mut ui_actions.10,
&mut ui_actions.12,
ui_actions.11.as_deref(),
&mut ui_actions.11,
ui_actions.10.as_deref(),
&net_commands,
&mut ui_actions.1 .3,
play_seq.next(),
@@ -1134,8 +1144,8 @@ pub(crate) fn apply_net_updates(
item_guid,
bag_slot,
&mut ui_actions.2,
&mut ui_actions.5,
&mut ui_actions.6,
&mut ui_actions.7,
&mut loot_latch,
),
SessionEvent::Chat(m) => {
@@ -1170,9 +1180,9 @@ pub(crate) fn apply_net_updates(
SessionEvent::ChatRestricted => {
chat::broadcast(crate::ui_chat::Broadcast::ChatRestricted, &mut chat_log)
}
SessionEvent::Notification { text } => chat::notification(text, &mut ui_actions.14),
SessionEvent::Notification { text } => chat::notification(text, &mut ui_actions.13),
SessionEvent::AreaTriggerMessage { text } => {
chat::area_trigger_message(text, &mut ui_actions.14)
chat::area_trigger_message(text, &mut ui_actions.13)
}
SessionEvent::PlayedTime { total, level } => {
// BOTH halves, and they are not redundant. The chat breakdown is our stand-in for
@@ -1419,7 +1429,7 @@ pub(crate) fn apply_net_updates(
&net_commands,
),
SessionEvent::LootError { guid, error } => {
loot_error(guid, error, &mut ui_actions.4, &mut loot_latch)
loot_error(guid, error, &mut ui_error_keys, &mut loot_latch)
}
SessionEvent::LootRemoved { slot } => loot_removed(slot, &mut loot),
SessionEvent::LootMoneyNotify { amount } => loot_money_notify(amount),
@@ -1520,7 +1530,7 @@ pub(crate) fn apply_net_updates(
&index,
&self_guid,
&stores,
ui_actions.11.as_deref(),
ui_actions.10.as_deref(),
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
@@ -1539,7 +1549,7 @@ pub(crate) fn apply_net_updates(
&index,
&self_guid,
&stores,
ui_actions.11.as_deref(),
ui_actions.10.as_deref(),
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
@@ -1669,9 +1679,9 @@ pub(crate) fn apply_net_updates(
&index,
&mut audio.5,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
ui_actions.11.as_deref(),
&mut ui_actions.4,
&mut ui_actions.8,
ui_actions.10.as_deref(),
play_seq.next(),
),
SessionEvent::SpellGo {
@@ -1703,21 +1713,21 @@ pub(crate) fn apply_net_updates(
&mut audio.6,
&self_guid,
&stores,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.15,
&mut ui_actions.4,
&mut ui_actions.8,
&mut ui_actions.14,
&mut audio.7,
&mut audio.10,
&mut loot_latch,
(
&mut ui_actions.10,
ui_actions.11.as_deref(),
&mut ui_actions.9,
ui_actions.10.as_deref(),
&mut items,
&net_commands,
&mut pet_bar,
),
(
&mut ui_actions.12,
&mut ui_actions.11,
&mut audio.15 .2,
!audio.15 .4.is_empty(),
),
@@ -1736,20 +1746,20 @@ pub(crate) fn apply_net_updates(
&audio.4,
&mut audio.5,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.15,
&mut ui_actions.4,
&mut ui_actions.8,
&mut ui_actions.14,
play_seq.next(),
),
SessionEvent::SpellDelayed { caster, delay_ms } => spell_delayed(
caster,
delay_ms,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.4,
&mut ui_actions.8,
),
SessionEvent::CancelAutoRepeat => cancel_auto_repeat(
&mut ui_actions.12,
&mut ui_actions.11,
&self_guid,
&index,
&mut commands,
@@ -1757,15 +1767,15 @@ pub(crate) fn apply_net_updates(
),
SessionEvent::SpellCooldowns { caster, cooldowns } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar)
{
spell_cooldowns(caster, cooldowns, ui_actions.11.as_deref(), store);
spell_cooldowns(caster, cooldowns, ui_actions.10.as_deref(), store);
}
}
SessionEvent::ItemCooldown {
item_guid,
spell_id,
} => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.10),
} => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.9),
// The item-lifetime countdown's ONLY feed (decision 1933): park the deadline on the
// item store, exactly as the enchant timer below does — vmangos's own writer says the
// `ITEM_FIELD_DURATION` field the client also holds is not what it displays from.
@@ -1781,21 +1791,21 @@ pub(crate) fn apply_net_updates(
} => items.set_enchant_deadline(item_guid, slot, seconds),
SessionEvent::CooldownEvent { spell_id, caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar)
{
cooldown_event(spell_id, caster, store);
}
}
SessionEvent::ClearCooldown { spell_id, caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar)
{
clear_cooldown(spell_id, caster, store);
}
}
SessionEvent::CooldownCheat { caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
addressed_store(caster, &self_guid, &mut ui_actions.9, &mut pet_bar)
{
cooldown_cheat(caster, store);
}
@@ -1803,7 +1813,7 @@ pub(crate) fn apply_net_updates(
// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a
// wholesale replace and its zero-guid form is the teardown.
SessionEvent::PetSpells(spells) => {
pet::pet_spells(*spells, ui_actions.11.as_deref(), &mut pet_bar)
pet::pet_spells(*spells, ui_actions.10.as_deref(), &mut pet_bar)
}
SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar),
SessionEvent::PetActionFeedback { reason } => {
@@ -1812,12 +1822,25 @@ pub(crate) fn apply_net_updates(
SessionEvent::PetCastFailed { spell_id, reason } => {
pet::pet_cast_failed(spell_id, reason, &mut ui_actions.1 .0)
}
// The three pet-feedback arms and the pet's voice (decision 2039). Each was
// name-table-only until then; each is something the reference visibly does.
SessionEvent::PetTameFailure { reason } => {
pet::pet_tame_failure(reason, &mut ui_actions.1 .4)
}
SessionEvent::PetNameInvalid => pet::pet_name_invalid(&mut ui_error_keys),
SessionEvent::PetBroken => pet::pet_broken(&mut ui_error_keys),
SessionEvent::PetActionSound { pet_guid, talk } => {
pet::pet_action_sound(pet_guid, talk, &index, &mut audio.15 .5)
}
SessionEvent::PetDismissSound { model_id, position } => {
pet::pet_dismiss_sound(model_id, position, &mut audio.15 .6)
}
SessionEvent::ChannelStart {
spell_id,
duration_ms,
} => channel_start(spell_id, duration_ms, &mut ui_actions.13, &mut ui_actions.5),
} => channel_start(spell_id, duration_ms, &mut ui_actions.12, &mut ui_actions.4),
SessionEvent::ChannelUpdate { remaining_ms } => {
channel_update(remaining_ms, &mut ui_actions.13, &mut ui_actions.5)
channel_update(remaining_ms, &mut ui_actions.12, &mut ui_actions.4)
}
SessionEvent::AuraDuration { slot, remaining_ms } => {
aura_duration(slot, remaining_ms, &mut aura.0, aura.1.elapsed_secs_f64())
@@ -1936,7 +1959,7 @@ pub(crate) fn apply_net_updates(
npc::trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &net_commands)
}
SessionEvent::TrainerBuyFailed { error, .. } => {
npc::trainer_buy_failed(error, &mut ui_actions.8 .0)
npc::trainer_buy_failed(error, &mut ui_actions.7 .0)
}
SessionEvent::InvalidatePlayer { guid } => names::invalidate_player(guid, &mut names),
SessionEvent::ListStabledPets {
@@ -1947,7 +1970,7 @@ pub(crate) fn apply_net_updates(
SessionEvent::StableResult { result } => npc::stable_result(
result,
&mut stable_open,
&mut ui_actions.8 .1,
&mut ui_actions.7 .1,
&net_commands,
),
SessionEvent::TaxiNodesShown {
+275 -14
View File
@@ -16,7 +16,7 @@ use crate::net::ClientCommand;
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
use crate::ui_action::{UiError, UiErrorKeys};
use crate::ui_items::{EquipError, EquipErrors};
use crate::ui_loot::{LootErrors, LootLatch, LootState};
use crate::ui_loot::{LootLatch, LootState};
use crate::ui_loot_roll::LootRolls;
/// The wire `loot_type` values `SMSG_LOOT_RESPONSE`'s **cold-latch** admission accepts
@@ -106,20 +106,125 @@ pub(super) fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
errors.0.push(UiError::key(key));
}
/// The **default** arm releases — VERIFIED at the bytes (wow-re §5,
/// `system/object-layer/scratch/loot-anim-leg.md` §7.1).
///
/// The out-of-range branch is not merely *adjacent* to code 7's arm, it **is** code 7's arm:
///
/// ```text
/// 5eba11: 0f 87 a6 00 00 00 ja 0x5ebabd ; 0x5eba17 + 0xa6 = 0x5ebabd
/// 5ebabd: 68 83 00 00 00 push 0x83 ; 5 bytes, ending exactly at 0x5ebac2
/// 5ebac2: e8 59 ac ea ff call 0x496720 ; the release tail, reached by FALL-THROUGH
/// ```
///
/// Jump-table slot 3 (`0x5ebc70`) holds the same `bd ba 5e 00`, a dword that occurs exactly once
/// in the image. So every code the table does not name shows `ERR_LOOT_DIDNT_KILL` **and** ends
/// the kneel — and the default's reach is wider than the enum suggests: `0x5eba07` is a `movzx`
/// and the `ja` is **unsigned**, so 0..3 and 15..255 all land here.
const DEFAULT_ARM_RELEASES: bool = true;
/// One arm of the loot-refusal jump table: the message it displays, and whether it falls through
/// the **release tail**.
///
/// Both halves are the reference's, read off the same eleven-entry table — each arm is a
/// `push <stringId>` that either falls into the tail at `0x5ebac2` or jumps past it, so "what does
/// it say" and "does the kneel end" are one decision in the bytes and one decision here.
struct LootRefusal {
/// The `GlobalStrings` key of the id the arm hands `CGGameUI::DisplayError` (`0x496720`).
key: &'static str,
/// Whether the arm falls into the release tail at `0x5ebac2` — zero the pending-loot guid
/// (`[player+0x1d28/+0x1d2c]`), `UnlockItem 0x495420`, `RecomputeBaseAnim 0x5fd9e0(-1)`.
/// That guid **is** benilla's [`LootLatch`], so this is exactly what ends the
/// client-predicted loot kneel.
releases: bool,
}
/// The refusal a `SMSG_LOOT_RESPONSE` error code produces — **the reference's own jump table**,
/// transcribed (wow-re `system/ui/scratch/loot-slot-record.md` §1, byte-verified).
///
/// The error leg is entered on `lootType == 0` (`0x5eb9f4`), reads the code (`0x5eba02`) and
/// dispatches `0x5eba0b add eax,-4; cmp eax,0xa; ja <default>; jmp [4*eax + 0x5ebc64]`. The
/// eleven-entry table read out of the PE gives:
///
/// | wire code | stringId | key | releases |
/// |---|---|---|---|
/// | 4 `TOO_FAR` | `0x82` (130) | `ERR_LOOT_TOO_FAR` | yes |
/// | 5 `BAD_FACING` | `0x84` (132) | `ERR_LOOT_BAD_FACING` | yes |
/// | 6 `LOCKED` | `0x81` (129) | `ERR_LOOT_LOCKED` | yes |
/// | 7 (unnamed server-side) | `0x83` (131) | `ERR_LOOT_DIDNT_KILL` | yes |
/// | 8 `NOTSTANDING` | `0x85` (133) | `ERR_LOOT_NOTSTANDING` | yes |
/// | 9 `STUNNED` | `0x86` (134) | `ERR_LOOT_STUNNED` | yes |
/// | 10 `PLAYER_NOT_FOUND` | `0x19b` (411) | `ERR_LOOT_PLAYER_NOT_FOUND` | **no** |
/// | 11 `PLAY_TIME_EXCEEDED` | `0x1bf` (447) | `ERR_PLAY_TIME_EXCEEDED` | yes |
/// | 12 `MASTER_INV_FULL` | `0x1cd` (461) | `ERR_LOOT_MASTER_INV_FULL` | **no** |
/// | 13 `MASTER_UNIQUE_ITEM` | `0x1ce` (462) | `ERR_LOOT_MASTER_UNIQUE_ITEM` | **no** |
/// | 14 `MASTER_OTHER` | `0x1cf` (463) | `ERR_LOOT_MASTER_OTHER` | **no** |
///
/// **The four that do not release are the four that arrive at an already-open window** — the
/// master looter's three `CMSG_LOOT_MASTER_GIVE` refusals (decision 1675) and the dead/absent
/// recipient. The corpse stays open and the character stays kneeling, which is why the reference
/// jumps past the tail for exactly these.
///
/// **Everything outside 4..=14 takes the default arm**, `ERR_LOOT_DIDNT_KILL` — including code
/// `0` (`DIDNT_KILL` itself, which reaches its own sentence only through the default) and vmangos's
/// `ALREADY_PICKPOCKETED` (15) / `NOT_WHILE_SHAPESHIFTED` (16). 1.12 ships **no** string for an
/// already-picked pocket or a shapeshifted refusal; the client says "You don't have permission to
/// loot that corpse." for both, and inventing a better sentence is not fidelity.
fn loot_refusal(reason: u8) -> LootRefusal {
use benilla_protocol::messages::loot_error as e;
// `7` has no vmangos name — it is a gap in the server enum — but it is a real arm of the
// client's table, and a *releasing* one, which is why it cannot simply fall to the default.
const UNNAMED_DIDNT_KILL: u8 = 7;
let (key, releases) = match reason {
e::TOO_FAR => ("ERR_LOOT_TOO_FAR", true),
e::BAD_FACING => ("ERR_LOOT_BAD_FACING", true),
e::LOCKED => ("ERR_LOOT_LOCKED", true),
UNNAMED_DIDNT_KILL => ("ERR_LOOT_DIDNT_KILL", true),
e::NOTSTANDING => ("ERR_LOOT_NOTSTANDING", true),
e::STUNNED => ("ERR_LOOT_STUNNED", true),
e::PLAYER_NOT_FOUND => ("ERR_LOOT_PLAYER_NOT_FOUND", false),
e::PLAY_TIME_EXCEEDED => ("ERR_PLAY_TIME_EXCEEDED", true),
e::MASTER_INV_FULL => ("ERR_LOOT_MASTER_INV_FULL", false),
e::MASTER_UNIQUE_ITEM => ("ERR_LOOT_MASTER_UNIQUE_ITEM", false),
e::MASTER_OTHER => ("ERR_LOOT_MASTER_OTHER", false),
_ => ("ERR_LOOT_DIDNT_KILL", DEFAULT_ARM_RELEASES),
};
LootRefusal { key, releases }
}
/// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't kill
/// it, too far, not standing, …). The refusal also ends the client-predicted kneel: the latch
/// armed at the `CMSG_LOOT` send drops (guid-matched), or the character would kneel forever at a
/// corpse whose window never opened (decision 0515).
pub(super) fn loot_error(
guid: u64,
error: u8,
loot_errors: &mut LootErrors,
latch: &mut LootLatch,
) {
// The server refused to open the window → the red UI error line (equip-error path).
debug!("net: loot error {error} on {guid:#x}");
loot_errors.0.push(error);
latch.clear_for(guid);
/// it, too far, not standing, …): the message by **GlobalStrings key**, never a sentence of ours,
/// and the kneel dropped only on the arms that release ([`loot_refusal`]).
///
/// The key route is not a style preference. The reference displays every one of these through
/// `DisplayError(stringId)`, so the *surface* (red `UIErrorsFrame`, from the catalog row's `+0x04`)
/// and the *voice line* (`+0x0c` — four of these speak in the player's own race and gender) are
/// properties of the message, and a hardcoded English string throws all three away along with
/// localization. Before this, benilla composed its own eight sentences here and six of them said
/// something the client never says.
///
/// **Two gaps this deliberately does not close** (surfaced by the wow-re §5, named here rather
/// than left to a later bug report):
///
/// 1. **A guid-MISMATCHED error takes a different arm in the reference.** The error leg is
/// reachable only past the admission gate, so the reference only ever *displays* a refusal for
/// the session it asked about; a mismatched one falls to `0x5eb963`, which clears the latch
/// unconditionally, shows **nothing**, and (because `lootType == 0`) sends nothing. benilla has
/// no admission gate on the error shape — it displays the line and keeps the latch. Reachable
/// only under the corpse-switch race, and it belongs with the gate (1477), not with the text.
/// 2. **`UnlockItem` fires `ITEM_LOCK_CHANGED` (188)** on the way through the tail, clearing
/// `[item+0x314]` bit 0. That matters only when the loot source is an ITEM guid — a lockbox —
/// where a refused open should drop the item's pending lock; for a corpse or chest guid there
/// is no item to unlock. benilla does not touch [`LockTransitions`] here.
pub(super) fn loot_error(guid: u64, error: u8, errors: &mut UiErrorKeys, latch: &mut LootLatch) {
let LootRefusal { key, releases } = loot_refusal(error);
debug!("net: loot error {error} on {guid:#x} → {key} (releases: {releases})");
errors.0.push(UiError::key(key));
if releases {
// The latch armed at the `CMSG_LOOT` send drops (guid-matched — see [`LootLatch`]), or the
// character would kneel forever at a corpse whose window never opened (decision 0515).
latch.clear_for(guid);
}
}
/// One loot-window row was taken, by anyone (`SMSG_LOOT_REMOVED`) — the UI clears that row.
@@ -319,6 +424,162 @@ mod tests {
assert_eq!(g("ERR_FISH_ESCAPED"), "Your fish got away!");
}
/// **The refusal table is the reference's, by message id.** Each key must be the catalog row
/// whose id the binary's own jump table pushes to `DisplayError` (wow-re
/// `loot-slot-record.md` §1: `4→0x82 · 5→0x84 · 6→0x81 · 7→0x83 · 8→0x85 · 9→0x86 ·
/// 10→0x19b · 11→0x1bf · 12→0x1cd · 13→0x1ce · 14→0x1cf`, default `0x83`).
///
/// Asserting the **id** rather than the spelling is the point: the key text is only a name for
/// a number the client pushes, and the catalog is generated from that same table (decision
/// 1770). A key that reads plausibly but names the wrong row cannot survive this.
#[test]
fn every_refusal_names_the_message_id_the_reference_pushes() {
const TABLE: &[(u8, u16)] = &[
(4, 0x82),
(5, 0x84),
(6, 0x81),
(7, 0x83),
(8, 0x85),
(9, 0x86),
(10, 0x19b),
(11, 0x1bf),
(12, 0x1cd),
(13, 0x1ce),
(14, 0x1cf),
// The default arm: code 0 is `DIDNT_KILL` itself, 15 `ALREADY_PICKPOCKETED` and 16
// `NOT_WHILE_SHAPESHIFTED` have no string of their own, and 1/2/3 are server-enum gaps.
(0, 0x83),
(1, 0x83),
(15, 0x83),
(16, 0x83),
(200, 0x83),
];
for &(code, id) in TABLE {
let key = loot_refusal(code).key;
let record = benilla_ui::messages::by_key(key)
.unwrap_or_else(|| panic!("code {code} named {key}, which is not a catalog row"));
assert_eq!(
record.id, id,
"code {code} → {key} (id {}), but the reference pushes {id:#x}",
record.id
);
// Every one of these is a red `UIErrorsFrame` line, never a chat line — the record's
// `+0x04`, which is what benilla threw away by composing its own sentence.
assert_eq!(
record.kind,
benilla_ui::messages::MsgKind::Error,
"{key} is not a red line"
);
}
// **The four commonest refusals are SPOKEN**, in the player's own race and gender — their
// `type_tag` (`+0x0c`) is a `VocalUIEnum` line id rather than the `0x44` "play the cue"
// sentinel. A hardcoded sentence has no record, so it had no voice either; this is the
// third thing the key route gets back, after the wording and the surface.
let voiced = |key: &str| {
usize::from(benilla_ui::messages::by_key(key).expect(key).type_tag)
< benilla_formats::VOCAL_UI_LINES
};
for key in [
"ERR_LOOT_DIDNT_KILL",
"ERR_LOOT_BAD_FACING",
"ERR_LOOT_LOCKED",
"ERR_LOOT_TOO_FAR",
] {
assert!(voiced(key), "{key} lost its voice line");
}
// …and the rest are cue-tagged, so the split is asserted from both sides.
for key in ["ERR_LOOT_NOTSTANDING", "ERR_LOOT_MASTER_OTHER"] {
assert!(!voiced(key), "{key} unexpectedly carries a voice line");
}
}
/// **The regression for the invented English.** Every refusal key must resolve in the shipped
/// 1.12 `GlobalStrings.lua` to the sentence the real client shows. benilla used to compose
/// these itself and six of the eight said something 1.12 never says — "You are too far away to
/// loot that." for a string that ends "that corpse.", "You can't loot that from there." for
/// "You must be facing the corpse to loot it.", and a wholly invented "Those pockets are
/// already empty." for a code that has no string at all. Skips without client data.
#[test]
fn refusal_keys_resolve_to_the_real_1_12_sentences() {
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).expect(key);
for (code, want) in [
(4u8, "You are too far away to loot that corpse."),
(5, "You must be facing the corpse to loot it."),
(6, "Someone is already looting that corpse."),
(7, "You don't have permission to loot that corpse."),
(8, "You need to be standing up to loot something!"),
(9, "You can't loot anything while stunned!"),
(10, "Player not found"),
(11, "Maximum play time exceeded"),
(12, "That player's inventory is full"),
(13, "Player has too many of that item already"),
(14, "Can't assign item to that player"),
// The default arm, including the two vmangos codes 1.12 has no sentence for.
(0, "You don't have permission to loot that corpse."),
(15, "You don't have permission to loot that corpse."),
(16, "You don't have permission to loot that corpse."),
] {
assert_eq!(g(loot_refusal(code).key), want, "wire code {code}");
}
}
/// **The four refusals that arrive at an already-open window do not end the kneel.** The
/// reference's arms for 10 and the master trio jump *past* the release tail at `0x5ebac2`; the
/// rest fall into it. A master looter refused a hand-off keeps kneeling at the open corpse.
#[test]
fn only_the_window_less_refusals_drop_the_kneel() {
// The named releasing arms, plus the default — `ja 0x5ebabd` lands *on* code 7's arm and
// falls through the tail, and the compare is unsigned, so 0..3 and 15..255 all release.
for code in [4u8, 5, 6, 7, 8, 9, 11, 0, 1, 2, 3, 15, 16, 200, 255] {
let mut errors = UiErrorKeys::default();
let mut latch = LootLatch(Some(CORPSE));
loot_error(CORPSE, code, &mut errors, &mut latch);
assert_eq!(latch.0, None, "code {code} should release");
assert_eq!(errors.0.len(), 1, "code {code} shows exactly one line");
}
for code in [10u8, 12, 13, 14] {
let mut errors = UiErrorKeys::default();
let mut latch = LootLatch(Some(CORPSE));
loot_error(CORPSE, code, &mut errors, &mut latch);
assert_eq!(
latch.0,
Some(CORPSE),
"code {code} answers an OPEN window — the kneel stays"
);
}
}
/// The **error leg's** clear stays guid-matched, and the wow-re §5 showed this is not the
/// deviation 0515 took it for: it is behaviourally *identical* to the reference.
///
/// The tail's own store is unconditional, but the tail cannot be reached on a mismatch.
/// `0x5eb9f1` has exactly one predecessor image-wide — `0x5eb93e je`, which already required
/// latch ≠ 0 **and** both GUID dwords equal — and it has no fall-through predecessor
/// (`0x5eb9ee` is `ret 8`); the other predecessors of `0x5eb9f4` all require the loot type in
/// `{2,3,4}` and so leave at `0x5eb9f8 jne`. A mismatched error never gets here at all.
///
/// Read this beside [`a_refusal_drops_whatever_latch_was_live_not_just_a_matching_one`], which
/// asserts the opposite for a **different arm**: the admission gate `0x5eb963`/`0x5eb9d2`,
/// which *is* reached on a mismatch and does clear unconditionally. Not a contradiction — a
/// distinction the handler draws, and the reason the mismatch case is a separate open gap
/// (see [`loot_error`]).
#[test]
fn the_error_legs_clear_is_guid_matched() {
let mut errors = UiErrorKeys::default();
let mut latch = LootLatch(Some(CHEST));
loot_error(CORPSE, 8, &mut errors, &mut latch);
assert_eq!(latch.0, Some(CHEST));
}
/// A GameObject guid (HIGHGUID_GAMEOBJECT in the high dword) — a chest.
const CHEST: u64 = 0xF110_0000_0000_1234;
/// A creature guid — a corpse.
+180 -25
View File
@@ -6,11 +6,13 @@
//! [`crate::ui_pet`]'s, latched on the press because the server never answers one (that file's
//! module doc has the why). The only state this file writes is what actually arrives on the wire.
//!
//! The two refusal arms deliberately reuse the *player's* red-line queues rather than growing pet
//! copies: both resolve their text through the VM's own `GlobalStrings.lua` the same way, and the
//! cast-fail resolver keys its power family on the failing **spell's** record ([`cast_fail`]'s
//! `power_keys(spell.power_type)`), so a pet's focus ability already reads "Not enough focus"
//! with no pet-awareness anywhere in the display layer.
//! The two refusal arms share the player's red-line **queues** — one drain, one sink, one
//! GlobalStrings lookup — but they do **not** share his message table, and believing they did is
//! what decision 2033 corrects. The reference gives each of these packets its own handler with its
//! own reason -> errorId map (`0x4bdb70` for the feedback byte, `0x6e8eb0` for the cast refusal),
//! and both maps exist precisely to say "your **pet** is dead / rooted / out of range" where the
//! player's says "you are". So the caster rides the queue as far as [`cast_fail`], which picks the
//! table; everything downstream of that stays pet-unaware, as it should be.
//!
//! [`cast_fail`]: crate::ui_action
@@ -20,7 +22,10 @@ use bevy::prelude::*;
use benilla_protocol::messages::{PetMode, PetSpells};
use crate::ui_action::{CastErrors, Spells, UiError, UiErrorKeys};
use benilla_assets::coords::wow_to_bevy;
use super::super::{GuidIndex, PetDismissSoundMessage, PetTalkMessage};
use crate::ui_action::{CastErrors, PetTameFailures, Spells, UiError, UiErrorKeys};
use crate::ui_pet::PetBar;
/// `SMSG_PET_SPELLS` — replace the whole bar, and reseed the pet's own cooldown store from the
@@ -136,31 +141,151 @@ pub(super) fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) {
}
}
/// The `SMSG_PET_ACTION_FEEDBACK` reason → its `GlobalStrings.lua` key.
/// The `SMSG_PET_ACTION_FEEDBACK` reason → the message-catalog row the reference raises for it.
///
/// vmangos sends exactly two (`Unit::SendPetActionFeedback`'s call sites): `1` when the pet cannot
/// path to the ordered spot, `2` when the ordered target is out of its reach. Both keys ship in
/// 1.12's `GlobalStrings.lua` (`PET_SPELL_NOPATH` at l.3054, `SPELL_FAILED_OUT_OF_RANGE`), so the
/// text — and its localization — comes from the VM like every other error line.
/// **The whole map, read off the handler** (`0x4bdb70`, decision 2033). The byte is decremented
/// and bounded — `dec eax; cmp eax,0x3; ja` — then indexes the four-entry jump table at
/// `0x4bdbe8`, so `0` and anything from `5` up fall past every arm and display nothing. Each arm
/// is a bare `push <errorId>; call CGGameUI::DisplayError 0x496720`, with no argument and no
/// state write: this opcode is a message and nothing else.
///
/// | byte | vmangos `PetFeedback` | errorId | catalog row |
/// |---|---|---|---|
/// | `1` | `FEEDBACK_PET_DEAD` | `0x150` | `ERR_PET_SPELL_DEAD` |
/// | `2` | `FEEDBACK_NOTHING_TO_ATT` | `0x0a0` | `ERR_NO_ATTACK_TARGET` |
/// | `3` | `FEEDBACK_CANT_ATT_TARGET` | `0x0a1` | `ERR_INVALID_ATTACK_TARGET` |
/// | `4` | `FEEDBACK_NO_PATH_TO` | `0x151` | `ERR_PET_SPELL_NOPATH` |
///
/// The four line up one-for-one with `Pet.h`'s enum and with its four `SendPetActionFeedback`
/// call sites. **That is what the two-entry map this replaces got wrong**: it read the sends as
/// "exactly two" and paired them with `1`/`2`, so a dead pet's refusal said "No path available
/// for your pet", a targetless attack said "Out of range", and `3`/`4` said nothing at all.
///
/// **`ERR_PET_SPELL_NOPATH` has no `GlobalStrings.lua` string in 5875**, so a no-path order is
/// silent in the reference too — its own data-suppression face, not a gap here. The key
/// `PET_SPELL_NOPATH` ("No path available for your pet") *does* exist, which is what made the old
/// map look right on screen; it is not this row's key, and nothing in the shipped client raises
/// it.
fn pet_feedback_key(reason: u8) -> Option<&'static str> {
Some(match reason {
1 => "PET_SPELL_NOPATH",
2 => "SPELL_FAILED_OUT_OF_RANGE",
1 => "ERR_PET_SPELL_DEAD",
2 => "ERR_NO_ATTACK_TARGET",
3 => "ERR_INVALID_ATTACK_TARGET",
4 => "ERR_PET_SPELL_NOPATH",
_ => return None,
})
}
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, queued onto the SAME red line as our own
/// through [`CastErrors`], because the resolver is spell-keyed and needs no pet-awareness.
/// `SMSG_PET_TAME_FAILURE` — the reason byte, straight onto [`PetTameFailures`] for the drain to
/// resolve (decision 2039).
///
/// What it deliberately does NOT do is touch our cast state: the caster is the pet, so there is no
/// pending cast of ours to revert, no GCD of ours to clear and no button of ours to unflash — the
/// three things `cast_result`'s own failure path does. Reusing that path would have made a pet's
/// refused Growl cancel the player's cast bar.
/// **Not only taming, despite the name**: vmangos raises it for Call Pet with no pet available
/// (`SpellEffects.cpp:3167`), Revive Pet on a live pet (`Spell.cpp:6136`), and any summon while a
/// pet is already out (`Spell.cpp:5463`) — which is why the reason vocabulary carries
/// `PETTAME_NOPETAVAILABLE`, `PETTAME_DEAD` and `PETTAME_NOTDEAD`, three values the taming spell
/// itself can never produce.
///
/// No local state moves: the refusal is the server's last word on a spell that never took, and
/// the client's own cast bookkeeping was already unwound by the `SMSG_CAST_RESULT` that came with
/// it.
pub(super) fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) {
debug!(
"net: pet tame failure {reason} ({})",
benilla_protocol::messages::pet_tame_failure_key(reason)
);
failures.0.push(reason);
}
/// `SMSG_PET_NAME_INVALID` — the server refused the rename; raise `ERR_INVALID_PETNAME`.
///
/// The packet carries nothing at all, so the opcode IS the message — which is exactly what the
/// reference's arm does (`0x5e3e33`: `push 0xf7; call 0x496720`, no body read). Decision 1066
/// recorded the opposite ("a refused rename silently does nothing") from a carve that had
/// attributed `SMSG_PET_BROKEN`'s handler to this opcode; the two are different functions.
///
/// Nothing else moves: the rename was optimistic-free by design (1066), so there is no local name
/// to roll back, and the popup has already closed.
pub(super) fn pet_name_invalid(errors: &mut UiErrorKeys) {
debug!("net: pet name refused");
errors.0.push(UiError::key("ERR_INVALID_PETNAME"));
}
/// `SMSG_PET_BROKEN` — the pet's loyalty hit zero and it left; raise `ERR_PET_BROKEN`
/// ("Your pet has run away").
///
/// Empty body, and the reference's handler `0x4bdc00` is five instructions that read none of it:
/// the *bar* teardown is not this packet's job. vmangos sends it immediately before
/// `Unsummon(PET_SAVE_AS_DELETED)` (`Pet.cpp:822`), so the zero-guid `SMSG_PET_SPELLS` that
/// actually clears the bar arrives on its own heels — which is why touching the bar here would be
/// a second, racing teardown rather than a fix.
pub(super) fn pet_broken(errors: &mut UiErrorKeys) {
debug!("net: pet ran away");
errors.0.push(UiError::key("ERR_PET_BROKEN"));
}
/// `SMSG_PET_ACTION_SOUND` — the pet's voice: resolve the guid, hand the selector to the sound
/// layer (decision 2039).
///
/// The reference resolves the packet's guid through the object manager and **drops the packet on
/// a miss** (`0x604101 je`), which is what the index lookup below is: a bark for a unit that has
/// not streamed in has nothing to play from and no position to play at.
///
/// The selector is not validated here — [`crate::sound::creature`]'s reader is where the
/// two-armed `cmp` lives, because that is where the reference has it too (`0x604106`/`0x60411c`).
pub(super) fn pet_action_sound(
pet_guid: u64,
talk: u32,
index: &GuidIndex,
talks: &mut MessageWriter<PetTalkMessage>,
) {
debug!("net: pet talk {talk} from {pet_guid:#x}");
if let Some(&unit) = index.0.get(&pet_guid) {
talks.write(PetTalkMessage { unit, talk });
}
}
/// `SMSG_PET_DISMISS_SOUND` — the parting sound's model id and point, converted into Bevy space
/// for the sound layer (decision 2039).
///
/// The `+1.0` on `z` is the reference's own (`0x6041d0 fadd [0x7ff9d8]`), applied **before** the
/// basis change because it is a WoW-space offset: the point on the wire is where the pet stood,
/// and the kit sounds a yard above it.
pub(super) fn pet_dismiss_sound(
model_id: u32,
position: [f32; 3],
sounds: &mut MessageWriter<PetDismissSoundMessage>,
) {
debug!("net: pet dismissed — model {model_id} at {position:?}");
sounds.write(PetDismissSoundMessage {
model_id,
pos: wow_to_bevy([position[0], position[1], position[2] + 1.0]),
});
}
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, queued onto the SAME red line as our own
/// through [`CastErrors`], but **marked as the pet's** so the display picks the reference's pet
/// message table rather than the player's (`push_pet`, decision 2033).
///
/// The reference handles this opcode in its own function, `Spell_C::HandlePetCastFailed`
/// (`0x6e8eb0`), which is not the player's `0x6e1a00` with a flag — it is a separate switch over
/// a separate 142-byte index table. Ten reasons resolve differently there, six of them to the
/// `ERR_PET_SPELL_*` rows that exist for no other purpose ("Your pet is in combat." where the
/// player reads "You are in combat"). The queue is shared; the table is not.
///
/// Two things it deliberately does NOT do, both because the caster is the pet:
///
/// - **Touch our cast state.** There is no pending cast of ours to revert, no GCD of ours to
/// clear and no button of ours to unflash — the three things `cast_result`'s own failure path
/// does. Reusing that path would have made a pet's refused Growl cancel the player's cast bar.
/// - **Write a combat-log line.** `0x6e1a00` calls the log formatter `0x62c360` beside its
/// `DisplayError`; `0x6e8eb0` calls neither it nor the error-sound `0x458a50` — its whole call
/// set is the two packet readers, `0x496720`, and the string plumbing behind it. So a pet's
/// refusal never prints "You fail to cast Growl: ..." in the log, and the drain's combat twin
/// skips it.
pub(super) fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
if let Some(reason) = reason {
errors.push_local(spell_id, reason);
errors.push_pet(spell_id, reason);
}
}
@@ -237,17 +362,47 @@ mod tests {
assert!(!bar.attacking, "a different pet is not attacking");
}
/// Only the two codes vmangos actually sends resolve; anything else draws nothing rather than
/// putting a bare number on the red line.
/// The whole feedback map, in the reference's own order (`0x4bdbe8`) — and the bound that
/// makes `0` and everything from `5` up draw nothing rather than putting a bare number on the
/// red line.
///
/// Written as the four keys in sequence rather than four asserts because the defect this
/// replaces was an *ordering* one: a two-entry map that paired the right vocabulary with the
/// wrong bytes still looked plausible read one row at a time.
#[test]
fn only_the_shipped_feedback_codes_resolve() {
fn the_feedback_map_is_the_jump_table_at_0x4bdbe8() {
let mut errors = UiErrorKeys::default();
for reason in [0u8, 1, 2, 3, 200] {
for reason in [0u8, 1, 2, 3, 4, 5, 200] {
pet_action_feedback(reason, &mut errors);
}
assert_eq!(
errors.0.iter().map(|e| e.key).collect::<Vec<_>>(),
["PET_SPELL_NOPATH", "SPELL_FAILED_OUT_OF_RANGE"]
[
"ERR_PET_SPELL_DEAD",
"ERR_NO_ATTACK_TARGET",
"ERR_INVALID_ATTACK_TARGET",
"ERR_PET_SPELL_NOPATH",
]
);
}
/// Every arm names a real `DisplayError` row — which is the check the old map could not have
/// passed, and was never asked to: neither `PET_SPELL_NOPATH` nor `SPELL_FAILED_OUT_OF_RANGE`
/// is a catalog key, and `message_keys`' source walk only reads `ERR_*` literals, so both
/// slipped past it. Naming the rows is also what gives these lines their surface and their
/// error-speech: `ERR_NO_ATTACK_TARGET` and `ERR_INVALID_ATTACK_TARGET` carry type tags
/// `0x26`/`0x0b`, so the client speaks them.
#[test]
fn every_feedback_arm_is_a_catalog_row() {
for reason in 1..=4u8 {
let key = pet_feedback_key(reason).expect("an arm");
let row = benilla_ui::messages::by_key(key)
.unwrap_or_else(|| panic!("{key} is not a message-catalog row"));
assert_eq!(
row.kind,
benilla_ui::messages::MsgKind::Error,
"{key} is the red line"
);
}
}
}
@@ -184,6 +184,8 @@ pub(super) fn cast_result(
spell_id,
reason,
arg,
// `SMSG_CAST_FAILED` is addressed to the caster, and this handler is ours.
caster: crate::ui_action::Caster::Player,
});
}
}
+32 -20
View File
@@ -182,8 +182,11 @@ pub(crate) fn material_variant(
/// `CharacterModelBase` (`0x3f8` B, the source-path string at `0x84351c`) and runs the ctor
/// `0x505680`. `DressUpModel`/`TabardModel` are subclasses of the same base — same light.
/// - That ctor configures the widget's embedded `CGLight` at `+0x324`: **directional**
/// (`0x71b620(light, 0)` @`0x5056c9`), **to-light direction `(0, 1, 0)`** (`0x71b6a0` @`0x505761`
/// — it overwrites an earlier `(0, −0.7071, −0.7071)` set at `0x5056e9`, dead code), **diffuse
/// (`0x71b620(light, 0)` @`0x5056c9`), **direction `(0, 1, 0)` — the direction the light
/// PROPAGATES** (`0x71b6a0` @`0x505761` writes `CGLight+0x24`, which `0x71bce0` negates — three
/// `fchs` at `71be7c`/`71be81`/`71be86` — before building the SH basis, wow-re
/// `modelframe-facing-cancels.md` Q4; 0638 read the field as a to-light vector, 2034 corrects
/// it — it overwrites an earlier `(0, −0.7071, −0.7071)` set at `0x5056e9`, dead code), **diffuse
/// `(0.8, 0.8, 0.64)`** (`+0x3c`, @`0x505702`), **ambient `(0.7, 0.7, 0.7)`** (`+0x30`,
/// @`0x50572e`), then **enables** it (`0x71b780(light, 1)` @`0x50576a`).
/// - It is the widget's ONLY light: the per-frame fill callback `0x76d680` (registered on the
@@ -196,17 +199,21 @@ pub(crate) fn material_variant(
/// `glue-model-lighting.md §4`), so row 19's dial stays at the studio's `0.4` = intensity 1.0.
///
/// **Why this reads so much darker than the studio light, and why that is the point.** The
/// reference's light is a pure SIDE light: WoW `(0, 1, 0)` is the model's own left, which
/// [`wow_to_bevy`] maps to Bevy `(−1, 0, 0)` — square across the camera axis, since the body rig
/// puts the eye on `−Z` looking at the model's front ([`framing::body_frame`]). Everything the pane
/// actually shows the viewer is therefore lit by **ambient alone** (`0.7`), with the diffuse only
/// grazing the figure's screen-right side. The studio light aims its diffuse from the camera's own
/// reference's light is a pure SIDE light: it travels toward WoW `(0, 1, 0)`, the model's own left
/// — Bevy `(−1, 0, 0)` under [`wow_to_bevy`] — so it falls on the figure from its right, square
/// across the camera axis, since the body rig puts the eye on `−Z` looking at the model's front
/// ([`framing::body_frame`]). Everything the pane actually shows the viewer is therefore lit by
/// **ambient alone** (`0.7`), with the diffuse only grazing the figure's screen-left side (its own
/// right; 0638 had it grazing the screen-right side, the same grazing light from the opposite
/// side, which is why the A/B that approved 0638's look could not tell the two apart — 2034). The
/// studio light aims its diffuse from the camera's own
/// side, so the same surfaces got `0.58 + 0.85·(N·L≈1) ≈ 1.4` — roughly twice the reference, which
/// is exactly the "too brightly lit" the director reported.
pub(super) fn model_pane_light() -> LightBlob {
// The builder takes the light's PROPAGATION direction (it negates it back into a to-light
// vector for the SH fold), so the ctor's to-light `(0,1,0)` goes in negated.
let sun_dir = -benilla_assets::coords::wow_to_bevy([0.0, 1.0, 0.0]);
// vector for the SH fold) — and the ctor's `(0,1,0)` IS the propagation direction
// (`CGLight+0x24`, negated by `0x71bce0` before the SH basis), so it goes in as written.
let sun_dir = benilla_assets::coords::wow_to_bevy([0.0, 1.0, 0.0]);
LightBlob::model(
[0.7, 0.7, 0.7], // CharacterModelBase ctor, CGLight+0x30
[0.8, 0.8, 0.64], // CharacterModelBase ctor, CGLight+0x3c
@@ -250,8 +257,9 @@ mod tests {
/// The reference `<PlayerModel>` light, pinned at the values the 1.12 `CharacterModelBase`
/// constructor writes (decision 0638) — and, more importantly, pinned at the *consequence*:
/// its light is square across the body rig's view axis, so what the pane shows the viewer is
/// ambient-lit only. A flipped sign or a swapped axis here is exactly the bug this replaced
/// (a frontal studio key, ~2× the reference on every surface the director can see).
/// ambient-lit only. A swapped axis here is the bug 0638 replaced (a frontal studio key, ~2×
/// the reference on every surface the director can see); a flipped sign is 0638's own reading
/// of `CGLight+0x24` as a to-light vector, which 2034 corrected to the propagation direction.
#[test]
fn model_pane_light_is_the_reference_widget_light_across_the_view_axis() {
let blob = model_pane_light();
@@ -259,12 +267,18 @@ mod tests {
assert_eq!(rows[0][..3], [0.7, 0.7, 0.7], "ambient (CGLight+0x30)");
assert_eq!(rows[1][..3], [0.8, 0.8, 0.64], "diffuse (CGLight+0x3c)");
// Row 2 is the light's PROPAGATION direction; the ctor's to-light is WoW (0,1,0).
// Row 2 is the light's PROPAGATION direction, and the ctor's (0,1,0) IS that direction
// (`0x71bce0` negates `CGLight+0x24` before the SH basis — 2034); the to-light is its
// negation.
let sun = Vec3::from_slice(&rows[2][..3]);
assert!(
sun.abs_diff_eq(benilla_assets::coords::wow_to_bevy([0.0, 1.0, 0.0]), 1e-6),
"propagation = wow_to_bevy(0,1,0): {sun:?}"
);
let to_light = -sun;
assert!(
to_light.abs_diff_eq(Vec3::new(-1.0, 0.0, 0.0), 1e-6),
"to-light = wow_to_bevy(0,1,0): {to_light:?}"
to_light.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6),
"to-light = -wow_to_bevy(0,1,0): {to_light:?}"
);
// The body rig puts the eye on −Z looking at +Z (`framing::body_frame`), so a surface facing
// the camera has normal −Z: N·L = 0, i.e. NO diffuse on anything the pane shows front-on.
@@ -273,12 +287,10 @@ mod tests {
facing_camera.dot(to_light).abs() < 1e-6,
"the reference light grazes the pane's visible face, it does not key it"
);
// …while the figure's screen-right side (+X toward the camera's right is −X in world) is
// the one that catches it.
assert!(
Vec3::NEG_X.dot(to_light) > 0.99,
"lit from the viewer's right"
);
// …while the figure's screen-LEFT side (the camera's right is −X in world, so +X is its
// left) is the one that catches it — the model's own right, the side the light travels
// away from.
assert!(Vec3::X.dot(to_light) > 0.99, "lit from the viewer's left");
}
/// The studio light — the round portraits' — is the OPPOSITE arrangement on purpose: its key
+335 -52
View File
@@ -24,8 +24,8 @@ use benilla_world::schedule::WorldStage;
use super::kit::{
bark_chance_pass, object_sound_playing, play_kit, play_kit_ext, source_kit_playing,
stop_source_kit, unit_voice_playing, KitRef, Latch, PlayExtras, SoundCategory, SoundKits,
STAND_CHANCE, STAND_COOLDOWN,
stop_source_kit, stop_unit_voice, unit_voice_state, KitRef, Latch, PlayExtras, SoundCategory,
SoundKits, STAND_CHANCE, STAND_COOLDOWN,
};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -48,6 +48,108 @@ fn load_creature_voices(mut commands: Commands, assets: Option<Res<WorldAssets>>
}
}
/// `0x623a40`'s bark states, in the order its jump table `0x623afc` lists them — the number IS
/// the priority (higher wins, `0x623a82`). State `3` is a real arm that plays nothing and has no
/// known caller, so it has no constant.
const BARK_AGGRO: u8 = 0;
const BARK_PET_ORDER: u8 = 1;
const BARK_PET_ATTACK: u8 = 2;
const BARK_DEATH: u8 = 4;
/// Step 3 of [`play_bark`] — `0x623a66`-`0x623a88`, the priority comparison alone.
///
/// `latched` is the state of the bark currently sounding on this unit, or `None` for a free slot.
/// The reference's two `je`s skip the comparison entirely when the handle is absent or finished,
/// so a free slot admits **every** state including `0`; a held slot admits only a *strictly*
/// higher one (`jle` aborts on equal, which is what keeps a burst of identical barks to one).
const fn bark_admitted(latched: Option<u8>, state: u8) -> bool {
match latched {
None => true,
Some(held) => state > held,
}
}
/// Step 5 of [`play_bark`] — the jump table at `0x623afc`, as a lookup. `0` means "this state
/// plays nothing", which covers both the table's own silent arm (state 3, `0x623af0`) and its
/// out-of-range default (`0x623aa0 cmp eax,4; ja`), and is also what an unpopulated column reads
/// as. All three are the same observable in the reference and the same `0` here.
const fn bark_kit(voice: &benilla_formats::CreatureVoice, state: u8) -> u32 {
match state {
BARK_AGGRO => voice.aggro,
BARK_PET_ORDER => voice.pet_order,
BARK_PET_ATTACK => voice.pet_attack,
BARK_DEATH => voice.death,
_ => 0,
}
}
/// **The creature bark dispatcher, whole** — `0x623a40(ecx = unit, state)`, the one function
/// every one-shot creature vocal below goes through (decision 2039). Four states are live here:
/// `0` the HOSTILE aggro flare, `1` the pet's ORDER bark, `2` the pet's ATTACK bark, `4` the
/// death cry. (State `3` exists in the table and plays nothing — it still stops and latches.)
///
/// Reading it as one function is the point: it was three call sites with three different subsets
/// of its gates before, and the pet barks could not be added correctly to any of them, because
/// what makes a pet bark audible over a pet's near-continuous aggro flares is exactly the
/// priority rule the aggro-only site had no reason to carry.
///
/// The order of operations is the reference's, and it is load-bearing:
///
/// 1. **No voice row, no bark** (`0x623a4a`) — the caller's `for_display` miss.
/// 2. **A server-pushed object sound live on this unit mutes it** (`0x623a59` → `0x4591f0`): a
/// scripted voice line is not talked over. Unlike the class route `0x623490`/`0x62f880`, this
/// one is a direct call, not a virtual — so it has **no player exemption**.
/// 3. **The priority latch** (`0x623a66`–`0x623a88`): while `[unit+0xb20]` holds a live handle, a
/// state `<=` the latched one is dropped outright. A free slot skips the comparison entirely.
/// 4. **Stop the old and latch the new** (`0x623a8d`/`0x623a95`) — *before* the column is read.
/// 5. **Then** resolve the column and play. **A zero column still silences step 4's victim** —
/// the handle is overwritten with the null result (`0x623aee`), so a state whose column is
/// unpopulated cuts short whatever it superseded and sounds nothing in its place. That is the
/// client's behaviour, not an oversight of ours. (It is not reachable through the pet barks on
/// a vmangos server: `SendPetTalk` is gated on `SUMMON_PET`, and only those four voices carry
/// the columns — see [`pet_talk_vocals`].)
#[allow(clippy::too_many_arguments)]
fn play_bark(
kits: &mut SoundKits,
assets: &WorldAssets,
out: &mut SoundOutput,
config: &SoundConfig,
listener: Vec3,
unit: Entity,
pos: Vec3,
voice: &benilla_formats::CreatureVoice,
state: u8,
) {
if object_sound_playing(out, unit) {
return;
}
if !bark_admitted(unit_voice_state(out, unit), state) {
return;
}
stop_unit_voice(out, unit);
let kit = bark_kit(voice, state);
if kit == 0 {
return;
}
if let Err(e) = play_kit_ext(
kits,
assets,
out,
config,
listener,
KitRef::Id(kit),
Some(pos),
SoundCategory::Sfx,
PlayExtras {
source: Some(unit),
latch: Latch::Voice(state),
..default()
},
) {
warn!("creature bark state {state} (kit {kit}): {e:#}");
}
}
/// Play the death vocal on a **live** death: a unit whose store transitions alive→dead. First
/// sight already-dead records silently (a streamed corpse doesn't cry — the same distinction the
/// animation driver makes for the settled-corpse pose).
@@ -80,26 +182,20 @@ fn death_vocals(
if !fresh_death {
continue;
}
let kit = net
.display_id
.and_then(|d| voices.0.for_display(d))
.map(|v| v.death)
.unwrap_or(0);
if kit == 0 {
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
continue;
}
if let Err(e) = play_kit(
};
play_bark(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("death vocal (kit {kit}): {e:#}");
}
entity,
transform.translation,
voice,
BARK_DEATH,
);
}
}
@@ -179,57 +275,172 @@ fn ai_reaction_vocals(
.and_then(|m| m.display_id)
.or(net.display_id)
};
let kit = display
.and_then(|d| voices.0.for_display(d))
.map(|v| if r.hostile { v.aggro } else { v.alert })
.unwrap_or(0);
if kit == 0 {
let Some(voice) = display.and_then(|d| voices.0.for_display(d)) else {
continue;
}
// The `AISOUNDDESC` gate (`0x4591f0`, from `0x6234cb` for ALERT's class route and
// `0x623a59` for HOSTILE's priority route): a server-pushed object sound live on this
// unit suppresses its own vocals, so a scripted voice line is not talked over. Applies to
// classes 0-3 and 8 — ALERT is 8 and HOSTILE takes the priority route, so both are in.
// The CGPlayer twin omits this gate entirely, hence the player exemption.
if net.kind != EntityKind::Player && object_sound_playing(&out, r.unit) {
continue;
}
// The `[unit+0xb20]` gate. Only HOSTILE latches the slot; ALERT neither tests nor stores
// it here (see the doc comment) and stays on the plain play.
if !r.hostile {
if let Err(e) = play_kit(
};
// HOSTILE is the priority route — `0x623a40(0)`, [`play_bark`], which owns both the
// `AISOUNDDESC` mute and the `[unit+0xb20]` latch.
if r.hostile {
play_bark(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("alert vocal (kit {kit}): {e:#}");
}
r.unit,
transform.translation,
voice,
BARK_AGGRO,
);
continue;
}
if unit_voice_playing(&out, r.unit) {
continue; // category 0 is the lowest: a live bark wins, this one is dropped
// ALERT is the *class* route (`vtable+0x88(8,0)` → `0x623490` → col 13) and is a
// different function: it neither tests nor stores the voice slot, so it stays a plain
// play. It does consult the same `AISOUNDDESC` pool (`0x4591f0` from `0x6234cb`) — but
// there through a virtual whose **CGPlayer twin `0x62f880` omits the gate**, which is why
// the player exemption lives on this leg and not in [`play_bark`].
if voice.alert == 0 {
continue;
}
if let Err(e) = play_kit_ext(
if net.kind != EntityKind::Player && object_sound_playing(&out, r.unit) {
continue;
}
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(voice.alert),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("alert vocal (kit {}): {e:#}", voice.alert);
}
}
}
/// The pet's voice (`SMSG_PET_ACTION_SOUND` → the net bridge; decision 2039): the two talk
/// selectors, straight onto [`play_bark`] at states 1 and 2.
///
/// The reference's handler `0x6040c0` is four instructions of its own once the guid is resolved:
/// selector `0` → `0x623a40(1)`, selector `1` → `0x623a40(2)`, **anything else → nothing** (there
/// is no default arm — `cmp eax,1; jne` falls to the epilogue). So the third value vmangos could
/// invent would be silent here too, and that is the client's answer, not a gap in ours.
///
/// **This is a warlock-demon surface, and the two ends say so independently.** Only four voices
/// in the shipped file carry these columns — the Imp, Succubus, Doomguard and Voidwalker
/// (`benilla_formats`' `only_the_four_demon_voices_carry_pet_barks`) — and vmangos only *sends*
/// the packet for `GetPetType() == SUMMON_PET`, i.e. those same demons, at a **10% roll** per
/// order (`Unit.cpp:8939`, `PetHandler.cpp:522`, `PetAI.cpp:335`; the other 90% is
/// `SendPetAIReaction`, the growl). A hunter's pet never reaches either half.
#[allow(clippy::too_many_arguments)]
fn pet_talk_vocals(
mut talks: MessageReader<crate::net::PetTalkMessage>,
units: Query<(&NetEntity, &Transform)>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
if talks.is_empty() {
return;
}
let (Some(voices), Some(mut kits), Some(assets)) = (voices, kits, assets) else {
return;
};
let listener = listener.pos;
for t in talks.read() {
let state = match t.talk {
benilla_protocol::messages::PET_TALK_ORDER => BARK_PET_ORDER,
benilla_protocol::messages::PET_TALK_ATTACK => BARK_PET_ATTACK,
_ => continue,
};
let Ok((net, transform)) = units.get(t.unit) else {
continue;
};
// `0x6040c0` resolves the guid with `0x468460(ecx = 8)` — the **TYPEMASK_UNIT** bit test,
// which a player's cumulative mask also carries (a mind-controlled player is somebody's
// pet too) but a GameObject's does not.
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// The pet's OWN voice row, not a mount-preferred one: `0x6040c0` resolves the packet's
// guid and hands that object straight to `0x623a40`, which reads `[unit+0xb40]` — the
// base row (`mount-composition.md` Q4's `+0xb44` redirect belongs to `0x60c480`, which is
// not on this path).
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
continue;
};
play_bark(
&mut kits,
&assets,
&mut out,
&config,
listener,
t.unit,
transform.translation,
voice,
state,
);
}
}
/// A dismissed pet's parting sound (`SMSG_PET_DISMISS_SOUND` → the net bridge; decision 2039) —
/// `CreatureSoundData` column 29, at a bare world point.
///
/// **Not a bark, and that is the whole shape of it.** `0x604140` never touches `0x623a40`: it
/// resolves the row *fresh by model id* (`CreatureModelData[id].SoundID` → `CreatureSoundData`,
/// no display step and no fallback), reads `[row+0x74]`, and plays it through the plain kit call
/// at the packet's own point — no unit, no voice latch, no `AISOUNDDESC` mute, no attach point.
/// It cannot be otherwise: there is no unit left to hang any of those on.
///
/// That fresh, inlined resolve is also why column 29 was believed dead — a census over the
/// consumers of the *cached* `[unit+0xb40]` row can never reach it (wow-re
/// `object-layer/scratch/pet-feedback-opcodes.md`).
///
/// **vmangos never sends this packet**, so nothing here fires against our server today. It is
/// built because the reference is the spec, and because it is what makes loading column 29 a
/// mechanism rather than a guess.
#[allow(clippy::too_many_arguments)]
fn pet_dismiss_sounds(
mut dismissals: MessageReader<crate::net::PetDismissSoundMessage>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
if dismissals.is_empty() {
return;
}
let (Some(voices), Some(mut kits), Some(assets)) = (voices, kits, assets) else {
return;
};
let listener = listener.pos;
for d in dismissals.read() {
let kit = voices
.0
.for_model(d.model_id)
.map(|v| v.pet_dismiss)
.unwrap_or(0);
if kit == 0 {
continue;
}
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
Some(d.pos),
SoundCategory::Sfx,
PlayExtras {
source: Some(r.unit),
latch: Latch::Voice,
..default()
},
) {
warn!("aggro vocal (kit {kit}): {e:#}");
warn!("pet dismiss sound (kit {kit}): {e:#}");
}
}
}
@@ -499,6 +710,8 @@ pub(super) fn plugin(app: &mut App) {
death_vocals,
creature_anim_vocals,
ai_reaction_vocals,
pet_talk_vocals,
pet_dismiss_sounds,
fall_landing_vocals,
creature_body_loops,
)
@@ -546,7 +759,7 @@ mod tests {
if slot.is_some_and(|(src, l, _)| occupies_voice_slot(src, l, bear)) {
continue; // category 0 is the lowest — a live bark wins, this one is dropped
}
slot = Some((Some(bear), Latch::Voice, t + clip));
slot = Some((Some(bear), Latch::Voice(BARK_AGGRO), t + clip));
sounded.push(t);
}
sounded
@@ -585,8 +798,78 @@ mod tests {
Entity::from_raw_u32(1).expect("valid entity id"),
Entity::from_raw_u32(2).expect("valid entity id"),
);
let barking = (Some(a), Latch::Voice);
let barking = (Some(a), Latch::Voice(BARK_AGGRO));
assert!(occupies_voice_slot(barking.0, barking.1, a));
assert!(!occupies_voice_slot(barking.0, barking.1, b));
}
/// **The priority rule, at every boundary the reference's two jumps and one `jle` draw**
/// (decision 2039). The equal case is the one that matters most: it is what collapses a burst
/// of identical barks to one, and it is why the aggro flood of 1399 stays collapsed now that
/// the slot carries a state instead of a bare bool.
#[test]
fn a_held_bark_admits_only_a_strictly_higher_state() {
// A free slot admits everything, including the lowest state.
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK, BARK_DEATH] {
assert!(bark_admitted(None, state), "free slot, state {state}");
}
// Equal is refused — `jle`, not `jl`.
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK, BARK_DEATH] {
assert!(!bark_admitted(Some(state), state), "equal, state {state}");
}
// A pet's ATTACK bark cuts through its own aggro flare and its own order bark — the whole
// reason the state had to become a payload: a hunter's pet draws aggro flares in bursts
// (1399), and under a bare liveness gate the pet's own voice would never be heard.
assert!(bark_admitted(Some(BARK_AGGRO), BARK_PET_ORDER));
assert!(bark_admitted(Some(BARK_AGGRO), BARK_PET_ATTACK));
assert!(bark_admitted(Some(BARK_PET_ORDER), BARK_PET_ATTACK));
// ...and nothing cuts through the death cry, which is the table's maximum.
assert!(bark_admitted(Some(BARK_PET_ATTACK), BARK_DEATH));
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK] {
assert!(
!bark_admitted(Some(BARK_DEATH), state),
"over death: {state}"
);
}
// The order bark does NOT interrupt an attack bark — the two pet states are ordered, and
// the wire sends them close together.
assert!(!bark_admitted(Some(BARK_PET_ATTACK), BARK_PET_ORDER));
}
/// The five-arm jump table, column for column — and its two silences.
#[test]
fn the_bark_table_maps_each_state_to_its_column() {
let voice = benilla_formats::CreatureVoice {
exertion: [0; 2],
injury: [0; 3],
death: 314,
stun: 0,
stand: 0,
footstep_class: 0,
aggro: 694,
wing_flap: 0,
wing_glide: 0,
alert: 1107,
fidget: [0; 4],
custom_attack: [0; 4],
loop_sound: 0,
impact_type: 0,
jump_start: 0,
jump_end: 0,
pet_attack: 9097,
pet_order: 9098,
pet_dismiss: 9096,
};
assert_eq!(bark_kit(&voice, BARK_AGGRO), 694);
assert_eq!(bark_kit(&voice, BARK_PET_ORDER), 9098);
assert_eq!(bark_kit(&voice, BARK_PET_ATTACK), 9097);
assert_eq!(bark_kit(&voice, BARK_DEATH), 314);
// State 3 is a real arm of the table that reads no column, and everything past 4 is the
// range check's default. Both play nothing — and neither is `alert` (col 13) nor
// `pet_dismiss` (col 29): those two reach their kits through entirely different
// functions, off this table altogether.
assert_eq!(bark_kit(&voice, 3), 0);
assert_eq!(bark_kit(&voice, 5), 0);
assert_eq!(bark_kit(&voice, 255), 0);
}
}
+47 -22
View File
@@ -160,18 +160,27 @@ pub(super) enum Latch {
Greeting,
/// The one-shot creature bark's `[unit+0xb20]` — the per-unit slot of the reference's bark
/// dispatch `0x623a40` (5-way jump table `0x623afc`; wow-re
/// `object-layer/scratch/smsg-ai-reaction.md`).
/// `object-layer/scratch/smsg-ai-reaction.md`, `feign-death-dyndead.md` §11).
///
/// The reference keeps the latched **category** beside the handle in `[unit+0xb24]` and uses
/// it for an interrupt rule — only a *strictly higher* category stops what is playing
/// (`0x623a8d`, after `0x623a95 call 0x7a5700`). benilla does not carry the category, because
/// the ordering of that 5-way table is an open wow-re question and guessing it would invent
/// an interrupt the client may not have. Today only the HOSTILE aggro bark (`0x623a40(0)`,
/// `CreatureSoundData` col 10) is routed through the slot, and it is byte-verified as the
/// table's **lowest** category: it never interrupts a playing bark and is dropped outright
/// while this latch is held — which is exactly what plain liveness already gives. When the
/// ordering is pinned, the category becomes a payload here.
Voice,
/// **The payload is the reference's `[unit+0xb24]` — the latched bark STATE**, which is the
/// interrupt rule's whole input: `0x623a82`/`0x623a88` abort a new bark whose state is `<=`
/// the one already sounding, and let a strictly higher one through (stopping the old at
/// `0x623a95`). It was left off until decision 2039 because the table's ordering was an open
/// question and inventing an interrupt is worse than missing one; the five arms are now read
/// out of the image dword for dword:
///
/// | state | `CreatureSoundData` column | what |
/// |---|---|---|
/// | 0 | 10 | the HOSTILE aggro bark (`SMSG_AI_REACTION`) |
/// | 1 | 28 | the pet's ORDER bark (`SMSG_PET_ACTION_SOUND` selector 0) |
/// | 2 | 27 | the pet's ATTACK bark (`SMSG_PET_ACTION_SOUND` selector 1) |
/// | 3 | — | plays nothing, and *still* stops and latches |
/// | 4 | 6 | the death bark — the maximum, so nothing supersedes it |
///
/// Carrying it on the channel is equivalent to carrying it on the unit, because the reference
/// only ever consults `[0xb24]` while `[0xb20]` holds a live handle (`0x623a74`/`0x623a7d`
/// jump past the comparison otherwise).
Voice(u8),
/// A **server-pushed object sound** live on this unit — `SMSG_PLAY_OBJECT_SOUND`
/// (opcode `0x278`), the `AISOUNDDESC` pool at `[0xb05f38]`.
///
@@ -786,18 +795,34 @@ pub(super) fn play_kit_ext(
}
/// Does `unit` hold a **live one-shot voice channel** — the reference's `[unit+0xb20]` handle,
/// nonzero-gated (wow-re `object-layer/scratch/smsg-ai-reaction.md`)? The slot's liveness IS the
/// gate: the HOSTILE aggro bark is the lowest category in `0x623a40`'s table, so it never
/// interrupts what is sounding and is simply dropped while this is true.
/// nonzero-gated (wow-re `object-layer/scratch/smsg-ai-reaction.md`) — and if so, at which
/// **state**? `Some(state)` is the pair `[0xb20]` live + `[0xb24]`; `None` is a free slot, which
/// is `0x623a74`/`0x623a7d`'s "allowed, no comparison" path.
///
/// Scoped to channels that actually latched a category, which is the whole point — the reference
/// Scoped to channels that actually latched a state, which is the whole point — the reference
/// keeps this handle separate from the combat drone (`0x623800` carries its own latch) and from
/// the greeting line (`[unit+0xb1c]`), so a humming elemental or a talking quest-giver must not
/// mute its own barks.
pub(super) fn unit_voice_playing(out: &SoundOutput, unit: Entity) -> bool {
out.channels
.iter()
.any(|c| occupies_voice_slot(c.source, c.latch, unit))
pub(super) fn unit_voice_state(out: &SoundOutput, unit: Entity) -> Option<u8> {
out.channels.iter().find_map(|c| match c.latch {
Latch::Voice(state) if c.source == Some(unit) => Some(state),
_ => None,
})
}
/// Stop whatever holds `unit`'s voice slot — the reference's `0x623a95 call 0x7a5700` on
/// `&[unit+0xb20]`, which runs on **every** admitted bark, before the column is even read. So a
/// bark whose column is `0` still silences the one it superseded: the handle is overwritten with
/// the (null) result of playing nothing (`0x623aee`).
pub(super) fn stop_unit_voice(out: &mut SoundOutput, unit: Entity) {
out.channels.retain_mut(|c| {
if occupies_voice_slot(c.source, c.latch, unit) {
c.handle.stop(mixer::declick());
false
} else {
true
}
});
}
/// Does one channel, described by its `(source, voice)` identity, occupy `unit`'s **voice** slot
@@ -806,7 +831,7 @@ pub(super) fn unit_voice_playing(out: &SoundOutput, unit: Entity) -> bool {
/// them: a bark and a greeting line are different handles in the reference and must not mute each
/// other.
pub(super) fn occupies_voice_slot(source: Option<Entity>, latch: Latch, unit: Entity) -> bool {
source == Some(unit) && latch == Latch::Voice
source == Some(unit) && matches!(latch, Latch::Voice(_))
}
/// The complement — the greeting latch's own test (see [`source_playing`] for what else currently
@@ -1377,7 +1402,7 @@ mod tests {
fn the_voice_slot_and_the_greeting_latch_are_disjoint() {
let bear = Entity::from_raw_u32(1).expect("valid entity id");
let other = Entity::from_raw_u32(2).expect("valid entity id");
let bark = (Some(bear), Latch::Voice);
let bark = (Some(bear), Latch::Voice(0));
let greet = (Some(bear), Latch::Greeting);
// The case decision 1399 was actually about: a channel tagged with the unit for
// *ownership* — its body loop, a missile's travel loop, a water splash — takes no latch
@@ -1498,7 +1523,7 @@ mod tests {
"a greeting line holds [unit+0xb1c] — stealing it lets the unit re-greet at once"
);
assert!(
!stealable(false, Latch::Voice),
!stealable(false, Latch::Voice(0)),
"a bark holds [unit+0xb20] — stealing it lets the unit re-bark at once"
);
assert!(
+384 -73
View File
@@ -10,6 +10,14 @@
//! screen shows "Spell is not ready yet." while `SPELL_FAILED_NOT_READY` reads
//! "Not yet recovered", and "Not enough rage" for a rage spell's NO_POWER.
//!
//! **This layer — and only this layer — is per-CASTER** (decision 2033). `SMSG_PET_CAST_FAILED`
//! is not the same handler with a flag: it is `Spell_C::HandlePetCastFailed 0x6e8eb0`, a
//! separate switch over a separate 142-byte index table, and it overrides a different ten
//! reasons. Six of them are the `ERR_PET_SPELL_*` catalog rows, which have no other raise site
//! in the client — "Your pet is out of range." where the player reads "Out of range." —
//! and seven of the player's overrides are simply absent there. [`Caster`] picks the table;
//! layers 1 and 3 are shared code in the binary and shared code here.
//!
//! 3. A per-reason **argument arm** (the second dispatch `0x6e1d8e`, 13 targets) fills that
//! message's own `%s`/`%d` from a DBC or item name before it is displayed. The two arms whose
//! tables benilla already loads live in [`FailArgs::fill`]; the drain fills three more before
@@ -40,6 +48,8 @@
use benilla_formats::{AreaTableCatalog, SpellDisplay, SpellFocusCatalog, SpellMechanicCatalog};
use super::Caster;
/// Wire reason → its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact).
pub(super) const CAST_FAIL_KEYS: [&str; 146] = [
"SPELL_FAILED_AFFECTING_COMBAT", // 0x00
@@ -315,6 +325,7 @@ impl CastFailLine {
/// VM's GlobalStrings lookup (an absent or empty key resolves to `None`, the data-suppression
/// face). Reasons beyond the table print their code — our debug affordance, not a ref string.
pub(super) fn cast_fail_text(
caster: Caster,
reason: u8,
spell: Option<&SpellDisplay>,
args: FailArgs<'_>,
@@ -322,45 +333,100 @@ pub(super) fn cast_fail_text(
) -> Option<CastFailLine> {
let get_text = |key: &str| get(key).filter(|s| !s.is_empty());
let get_display = |key: &'static str| get_text(key).map(|text| CastFailLine { key, text });
// The errorId overrides (`0x6e1aab–0x6e1c5f`): the replaced-message reasons.
match reason {
0x01 => return get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_HEALTH"),
0x02 => {
let t = get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S")?;
let power = spell.map_or(0, |d| d.power_type);
let name = get_text(power_keys(power).1).unwrap_or_default();
return Some(t.fill(&name));
}
0x09 => return get_display("ERR_GENERIC_NO_TARGET"),
0x17 => return None, // DONT_REPORT: control-flow hidden (jumps past DisplayError)
0x18 => return get_display("ERR_SPELL_FAILED_EQUIPPED_ITEM"),
0x28 => {
return get_display(if is_potion(spell) {
"ERR_POTION_COOLDOWN"
} else {
"ERR_ITEM_COOLDOWN"
});
}
0x3C => {
let key = if is_food(spell) {
"ERR_FOOD_COOLDOWN"
} else if is_potion(spell) {
"ERR_POTION_COOLDOWN"
} else if spell.is_some_and(|d| d.attributes & 0x10 != 0) {
"ERR_ABILITY_COOLDOWN"
} else {
"ERR_SPELL_COOLDOWN"
};
return get_display(key);
}
0x41 => return get_display("ERR_SPELL_FAILED_NOTUNSHEATHED"),
0x4D => {
let power = spell.map_or(0, |d| d.power_type);
return get_display(power_keys(power).0);
}
0x59 => return get_display("ERR_SPELL_OUT_OF_RANGE"),
0x8E => return get_display("ERR_PLAY_TIME_EXCEEDED"),
_ => {}
// The errorId overrides — the replaced-message reasons. **Which table is asked is the
// caster's** (decision 2033): the reference does not flag one handler, it ships two, and they
// disagree on ten reasons. Everything past this match — the `SPELL_FAILED_*` vocabulary, the
// argument arms, the strip fallback — is shared, exactly as it is in the binary.
match caster {
// `Spell_C::HandlePetCastFailed 0x6e8eb0`: `cmp reason,0x8d; ja default`, then a 142-byte
// index table (`0x6e93d0`) over 15 jump targets (`0x6e9394`). Six of the ten overrides are
// the `ERR_PET_SPELL_*` catalog rows, which exist for this handler and nothing else —
// "Your pet is dead." where the player reads "You are dead".
Caster::Pet => match reason {
0x00 => return get_display("ERR_PET_SPELL_AFFECTING_COMBAT"), // 0x14c @0x6e8fab
0x13 => return get_display("ERR_PET_SPELL_DEAD"), // 0x150 @0x6e9017
0x32 => return get_display("ERR_PET_SPELL_NOPATH"), // 0x151 @0x6e9032
0x33 => return get_display("ERR_PET_SPELL_NOT_BEHIND"), // 0x14e @0x6e8fe1
0x59 => return get_display("ERR_PET_SPELL_OUT_OF_RANGE"), // 0x14d @0x6e8fc6
0x5F => return get_display("ERR_PET_SPELL_ROOTED"), // 0x14b @0x6e8f4f
0x65 => return get_display("ERR_PET_SPELL_TARGETS_DEAD"), // 0x14f @0x6e8ffc
// `0x6e8f2a` computes its id: `((SpellRec+0x18 & 0x10) | 0x300) >> 4`, which is only
// ever `0x31` or `0x30`. The player's four-way (`0x6e1aab`) tests the spell CATEGORY
// first for food and potion; the pet's does not test it at all, because a pet eats
// and drinks nothing.
0x3C => {
return get_display(if spell.is_some_and(|d| d.attributes & 0x10 != 0) {
"ERR_ABILITY_COOLDOWN"
} else {
"ERR_SPELL_COOLDOWN"
});
}
// `0x6e8f6a`: health (`SpellRec+0x7c` == `-2`) takes `0x123`, everything else indexes
// `[0x8118f0 + 4*power]`. Those five dwords are byte-identical to the player's table
// at `0x8118dc` (`0x11f 0x120 0x121 0x122 0x168`), so this is the SAME pick, not a
// pet-specific one — which is why [`power_keys`] serves both and a hunter pet's focus
// ability reads "Not enough focus" either way.
0x4D => {
let power = spell.map_or(0, |d| d.power_type);
return get_display(power_keys(power).0);
}
// What is deliberately absent is as load-bearing as what is here: the pet's index
// table sends `0x01`/`0x02` (already at full health/power), `0x09` (no target),
// `0x18` (equipped item), `0x28` (item cooldown), `0x41` (unsheathed) and `0x8e`
// (play time) to the generic arm, so a pet never says any of those seven lines.
//
// `0x17` DONT_REPORT is the one that looks like a divergence and is not: the player's
// handler hides it by control flow, the pet's routes it to the generic arm — but
// `SPELL_FAILED_DONT_REPORT` has no string in 5875's `GlobalStrings.lua`, so the
// passthrough below draws nothing and the two agree on screen.
//
// `0x56` ONLY_SHAPESHIFT has a real arm here (`0x6e921a`, errorId `0xd6`) that fills
// the template with a `SpellShapeshiftForm.dbc` name and displays it through the bare
// `"%s"` of `ERR_SPELL_FAILED_SHAPESHIFT_FORM_S` — the same net line the player's own
// unmodeled arm would produce, and stripped the same way here for the same reason
// (that DBC is not loaded). Same surface, same silence, no pet-specific work.
_ => {}
},
// `Spell_C::HandleCastFailed 0x6e1a00` (`0x6e1aab`–`0x6e1c5f`).
Caster::Player => match reason {
0x01 => return get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_HEALTH"),
0x02 => {
let t = get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S")?;
let power = spell.map_or(0, |d| d.power_type);
let name = get_text(power_keys(power).1).unwrap_or_default();
return Some(t.fill(&name));
}
0x09 => return get_display("ERR_GENERIC_NO_TARGET"),
0x17 => return None, // DONT_REPORT: control-flow hidden (jumps past DisplayError)
0x18 => return get_display("ERR_SPELL_FAILED_EQUIPPED_ITEM"),
0x28 => {
return get_display(if is_potion(spell) {
"ERR_POTION_COOLDOWN"
} else {
"ERR_ITEM_COOLDOWN"
});
}
0x3C => {
let key = if is_food(spell) {
"ERR_FOOD_COOLDOWN"
} else if is_potion(spell) {
"ERR_POTION_COOLDOWN"
} else if spell.is_some_and(|d| d.attributes & 0x10 != 0) {
"ERR_ABILITY_COOLDOWN"
} else {
"ERR_SPELL_COOLDOWN"
};
return get_display(key);
}
0x41 => return get_display("ERR_SPELL_FAILED_NOTUNSHEATHED"),
0x4D => {
let power = spell.map_or(0, |d| d.power_type);
return get_display(power_keys(power).0);
}
0x59 => return get_display("ERR_SPELL_OUT_OF_RANGE"),
0x8E => return get_display("ERR_PLAY_TIME_EXCEEDED"),
_ => {}
},
}
// The passthrough layer: errorId 0x2c ("%s") displays GetText(SPELL_FAILED_<name>) as-is.
let Some(key) = CAST_FAIL_KEYS.get(usize::from(reason)) else {
@@ -420,9 +486,174 @@ mod tests {
"ERR_SPELL_FAILED_NOTUNSHEATHED",
"You have nothing to attack with.",
),
// The pet's own six, verbatim from the shipped file — and NO `ERR_PET_SPELL_NOPATH`,
// because 5875 ships none, which is a fact these tests rest on.
("ERR_PET_SPELL_AFFECTING_COMBAT", "Your pet is in combat."),
("ERR_PET_SPELL_DEAD", "Your pet is dead."),
(
"ERR_PET_SPELL_NOT_BEHIND",
"Your pet must be behind its target.",
),
("ERR_PET_SPELL_OUT_OF_RANGE", "Your pet is out of range."),
("ERR_PET_SPELL_ROOTED", "Your pet is unable to move."),
("ERR_PET_SPELL_TARGETS_DEAD", "Your pet\'s target is dead."),
("SPELL_FAILED_AFFECTING_COMBAT", "You are in combat"),
("SPELL_FAILED_CASTER_DEAD", "You are dead"),
("SPELL_FAILED_NOPATH", "No path available"),
("SPELL_FAILED_NOT_BEHIND", "You must be behind your target"),
("SPELL_FAILED_ROOTED", "You are unable to move"),
("SPELL_FAILED_TARGETS_DEAD", "Your target is dead"),
("SPELL_FAILED_BAD_IMPLICIT_TARGETS", "No target"),
("SPELL_FAILED_NOT_UNSHEATHED", "You must be unsheathed"),
("ERR_OUT_OF_FOCUS", "Not enough focus"),
])
}
/// **The six lines that exist for the pet and nobody else.** Each is a row the client raises
/// only from `Spell_C::HandlePetCastFailed 0x6e8eb0`, and each is checked against what the
/// *player* gets for the same wire reason — because "our pet reads the same string we do" is
/// exactly the shape of the defect decision 2033 corrects, and a one-sided assert would not
/// have caught it.
#[test]
fn the_pet_speaks_its_own_six_refusals() {
let m = gs();
let g = getter(&m);
let say = |caster, reason| {
cast_fail_text(caster, reason, None, FailArgs::default(), &g).map(|l| l.text)
};
for (reason, pet, player) in [
(0x00, "Your pet is in combat.", "You are in combat"),
(0x13, "Your pet is dead.", "You are dead"),
(
0x33,
"Your pet must be behind its target.",
"You must be behind your target",
),
(0x59, "Your pet is out of range.", "Out of range."),
(
0x5F,
"Your pet is unable to move.",
"You are unable to move",
),
(0x65, "Your pet\'s target is dead.", "Your target is dead"),
] {
assert_eq!(
say(Caster::Pet, reason).as_deref(),
Some(pet),
"pet {reason:#04x}"
);
assert_eq!(
say(Caster::Player, reason).as_deref(),
Some(player),
"player {reason:#04x}"
);
}
}
/// **NOPATH is silent for the pet and spoken for the player**, which reads like a bug and is
/// the reference: the pet arm raises errorId `0x151`, whose key `ERR_PET_SPELL_NOPATH` has no
/// string in 5875's `GlobalStrings.lua`, so `DisplayError` shows nothing. The player's `0x32`
/// takes the passthrough and reads "No path available".
#[test]
fn the_pets_nopath_is_silent_because_5875_ships_no_string() {
let m = gs();
let g = getter(&m);
assert_eq!(
cast_fail_text(Caster::Pet, 0x32, None, FailArgs::default(), &g),
None
);
assert_eq!(
cast_fail_text(Caster::Player, 0x32, None, FailArgs::default(), &g)
.unwrap()
.text,
"No path available"
);
}
/// The pet's `0x3c` computes its id from one bit — `((SpellRec+0x18 & 0x10) | 0x300) >> 4` —
/// so it has only the ability/spell split. The player's tests the spell CATEGORY first and has
/// a food and a potion leg. A pet eats nothing, so a potion-category spell that says "Item is
/// not ready yet." for us says "Spell is not ready yet." for it.
#[test]
fn the_pet_has_no_food_or_potion_cooldown_leg() {
let m = gs();
let g = getter(&m);
let potion = spell(0, 4, 0);
let ability = spell(0, 0, 0x10);
let text = |caster, d| {
cast_fail_text(caster, 0x3C, Some(d), FailArgs::default(), &g)
.unwrap()
.text
};
assert_eq!(text(Caster::Player, &potion), "Item is not ready yet.");
assert_eq!(text(Caster::Pet, &potion), "Spell is not ready yet.");
// The one leg they share, so the collapse is a missing branch and not a missing table.
assert_eq!(text(Caster::Player, &ability), "Ability is not ready yet.");
assert_eq!(text(Caster::Pet, &ability), "Ability is not ready yet.");
}
/// The pet's index table sends seven of the player's overrides to its generic arm, so those
/// lines are the player's alone. Checked on the two with the sharpest tell: `0x09`, where the
/// text changes, and `0x41`, where the **surface** changes too — the player's override is the
/// one yellow cast failure in the game, and the pet's passthrough is red.
#[test]
fn the_pet_does_not_take_the_players_overrides() {
use benilla_ui::messages::{kind_of, MsgKind};
let m = gs();
let g = getter(&m);
let line = |caster, reason| {
cast_fail_text(caster, reason, None, FailArgs::default(), &g).expect("a line")
};
assert_eq!(line(Caster::Player, 0x09).text, "You have no target.");
assert_eq!(line(Caster::Pet, 0x09).text, "No target");
let mine = line(Caster::Player, 0x41);
assert_eq!(mine.key, "ERR_SPELL_FAILED_NOTUNSHEATHED");
assert_eq!(kind_of(mine.key), MsgKind::Info);
let its = line(Caster::Pet, 0x41);
assert_eq!(its.key, PASSTHROUGH);
assert_eq!(its.text, "You must be unsheathed");
assert_eq!(kind_of(its.key), MsgKind::Error);
}
/// `0x4d` NO_POWER is the override the two handlers **agree** on, and that is a byte fact
/// rather than an assumption: the pet's table at `0x8118f0` holds the same five dwords as the
/// player's at `0x8118dc`. So [`power_keys`] serves both, and a hunter pet's focus ability
/// reads the focus line on either path.
#[test]
fn the_pets_no_power_pick_is_the_players() {
let m = gs();
let g = getter(&m);
let focus = spell(2, 0, 0);
for caster in [Caster::Player, Caster::Pet] {
assert_eq!(
cast_fail_text(caster, 0x4D, Some(&focus), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough focus"
);
}
}
/// `0x17` DONT_REPORT looks like a divergence between the two handlers and is not: the
/// player's hides it by control flow, the pet's routes it to the generic arm — but
/// `SPELL_FAILED_DONT_REPORT` has no string in 5875, so both draw nothing. Worth a test
/// because the two mechanisms are different and only the *outcome* is shared, so a future
/// change to either one should have to notice.
#[test]
fn dont_report_is_silent_on_both_paths_for_two_different_reasons() {
let m = gs();
let g = getter(&m);
assert!(!m.contains_key("SPELL_FAILED_DONT_REPORT"));
for caster in [Caster::Player, Caster::Pet] {
assert_eq!(
cast_fail_text(caster, 0x17, None, FailArgs::default(), &g),
None
);
}
}
/// **The one cast failure that is not a red line.** Reason `0x41` overrides to
/// `ERR_SPELL_FAILED_NOTUNSHEATHED` (id 320), and that record's kind is `1` — the yellow
/// `UI_INFO_MESSAGE`, not the red `UI_ERROR_MESSAGE` its seventeen override neighbours take.
@@ -441,16 +672,19 @@ mod tests {
let m = gs();
let g = getter(&m);
let line = cast_fail_text(0x41, None, FailArgs::default(), &g).expect("a line");
let line =
cast_fail_text(Caster::Player, 0x41, None, FailArgs::default(), &g).expect("a line");
assert_eq!(line.key, "ERR_SPELL_FAILED_NOTUNSHEATHED");
assert_eq!(kind_of(line.key), MsgKind::Info);
// An override that IS red, and a passthrough that falls to errorId 0x2c.
let red = cast_fail_text(0x59, None, FailArgs::default(), &g).expect("a line");
let red =
cast_fail_text(Caster::Player, 0x59, None, FailArgs::default(), &g).expect("a line");
assert_eq!(red.key, "ERR_SPELL_OUT_OF_RANGE");
assert_eq!(kind_of(red.key), MsgKind::Error);
let through = cast_fail_text(0x43, None, FailArgs::default(), &g).expect("a line");
let through =
cast_fail_text(Caster::Player, 0x43, None, FailArgs::default(), &g).expect("a line");
assert_eq!(through.key, PASSTHROUGH);
assert_eq!(kind_of(through.key), MsgKind::Error);
}
@@ -487,25 +721,25 @@ mod tests {
let m = gs();
let g = getter(&m);
assert_eq!(
cast_fail_text(0x43, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x43, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x59, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of range."
);
assert_eq!(
cast_fail_text(0x76, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x76, None, FailArgs::default(), &g)
.unwrap()
.text,
"Target too close"
);
assert_eq!(
cast_fail_text(0x09, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x09, None, FailArgs::default(), &g)
.unwrap()
.text,
"You have no target."
@@ -513,21 +747,27 @@ mod tests {
// 0x3c: plain spell → spell cooldown; Attr&0x10 → ability; potion category → potion.
let plain = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&plain), FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x3C, Some(&plain), FailArgs::default(), &g)
.unwrap()
.text,
"Spell is not ready yet."
);
let ability = spell(1, 0, 0x10);
assert_eq!(
cast_fail_text(0x3C, Some(&ability), FailArgs::default(), &g)
.unwrap()
.text,
cast_fail_text(
Caster::Player,
0x3C,
Some(&ability),
FailArgs::default(),
&g
)
.unwrap()
.text,
"Ability is not ready yet."
);
let potion = spell(0, 4, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&potion), FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x3C, Some(&potion), FailArgs::default(), &g)
.unwrap()
.text,
"Item is not ready yet."
@@ -542,14 +782,14 @@ mod tests {
let g = getter(&m);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x4D, Some(&rage), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough rage"
);
let mana = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&mana), FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x4D, Some(&mana), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough mana"
@@ -563,16 +803,22 @@ mod tests {
fn suppression_hex_fallback_and_template_strip() {
let m = gs();
let g = getter(&m);
assert_eq!(cast_fail_text(0x17, None, FailArgs::default(), &g), None);
assert_eq!(cast_fail_text(0x08, None, FailArgs::default(), &g), None);
assert_eq!(
cast_fail_text(0x92, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x17, None, FailArgs::default(), &g),
None
);
assert_eq!(
cast_fail_text(Caster::Player, 0x08, None, FailArgs::default(), &g),
None
);
assert_eq!(
cast_fail_text(Caster::Player, 0x92, None, FailArgs::default(), &g)
.unwrap()
.text,
"Spell failed (0x92)"
);
assert_eq!(
cast_fail_text(0x5C, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x5C, None, FailArgs::default(), &g)
.unwrap()
.text,
"Missing reagent"
@@ -596,26 +842,26 @@ mod tests {
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
assert_eq!(
cast_fail_text(0x43, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x43, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x59, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of range."
);
assert_eq!(
cast_fail_text(0x3C, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x3C, None, FailArgs::default(), &g)
.unwrap()
.text,
"Spell is not ready yet."
);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x4D, Some(&rage), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough rage"
@@ -624,20 +870,72 @@ mod tests {
// player reads IS the GlobalStrings value. A typo'd key here would degrade a real refusal
// to a dead-looking button, which is what this test exists to catch.
assert_eq!(
cast_fail_text(0x50, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x50, None, FailArgs::default(), &g)
.unwrap()
.text,
"Cannot use while swimming"
);
assert_eq!(
cast_fail_text(0x58, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x58, None, FailArgs::default(), &g)
.unwrap()
.text,
"Can only use while swimming"
);
// The data-suppression face on the real file: the absent keys show nothing.
assert_eq!(cast_fail_text(0x08, None, FailArgs::default(), &g), None);
assert_eq!(cast_fail_text(0x21, None, FailArgs::default(), &g), None);
assert_eq!(
cast_fail_text(Caster::Player, 0x08, None, FailArgs::default(), &g),
None
);
assert_eq!(
cast_fail_text(Caster::Player, 0x21, None, FailArgs::default(), &g),
None
);
// The pet's table against the player's own shipped file (decision 2033). Both halves
// matter: the six rows resolve to the pet wording, and `ERR_PET_SPELL_NOPATH` resolves to
// NOTHING — the claim the `SILENT_IN_5875` entry rests on, checked here against the real
// `GlobalStrings.lua` rather than against a fixture that could simply have omitted it.
for (reason, pet, player) in [
(0x00, "Your pet is in combat.", "You are in combat"),
(0x13, "Your pet is dead.", "You are dead"),
(
0x33,
"Your pet must be behind its target.",
"You must be behind your target",
),
(0x59, "Your pet is out of range.", "Out of range."),
(
0x5F,
"Your pet is unable to move.",
"You are unable to move",
),
(0x65, "Your pet\'s target is dead.", "Your target is dead"),
] {
assert_eq!(
cast_fail_text(Caster::Pet, reason, None, FailArgs::default(), &g)
.map(|l| l.text)
.as_deref(),
Some(pet),
"pet {reason:#04x}"
);
assert_eq!(
cast_fail_text(Caster::Player, reason, None, FailArgs::default(), &g)
.map(|l| l.text)
.as_deref(),
Some(player),
"player {reason:#04x}"
);
}
assert_eq!(
cast_fail_text(Caster::Pet, 0x32, None, FailArgs::default(), &g),
None,
"5875 ships no ERR_PET_SPELL_NOPATH, so the reference shows nothing"
);
assert!(
g("PET_SPELL_NOPATH").is_some(),
"and the near-miss key that made the old map look right IS shipped — which is the \
whole trap"
);
// B255, end to end on the real data: the argument arms against the real DBCs and the real
// GlobalStrings templates. Without the fill these read as the bare stems "Requires" and
@@ -658,15 +956,19 @@ mod tests {
// the wire. The names are lower-case adjectives in the shipped data, which is what makes
// them read as the tail of the sentence.
assert_eq!(
cast_fail_text(0x8D, None, args(12), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x8D, None, args(12), &g)
.unwrap()
.text,
"Can't do that while stunned"
);
assert_eq!(
cast_fail_text(0x8D, None, args(5), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x8D, None, args(5), &g)
.unwrap()
.text,
"Can't do that while fleeing"
);
// An unknown or absent mechanic strips to the bare stem rather than showing a raw `%s`.
assert!(!cast_fail_text(0x8D, None, args(999), &g)
assert!(!cast_fail_text(Caster::Player, 0x8D, None, args(999), &g)
.unwrap()
.text
.contains('%'));
@@ -674,26 +976,34 @@ mod tests {
// 0x5e REQUIRES_SPELL_FOCUS: the Crown of the Earth phials' own refusal. Focus 12 is the
// Starbreeze Village moonwell — using the Jade Phial at any *other* pool is the report.
assert_eq!(
cast_fail_text(0x5E, None, args(12), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x5E, None, args(12), &g)
.unwrap()
.text,
"Requires Starbreeze Village Moonwell"
);
assert_eq!(
cast_fail_text(0x5E, None, args(1), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x5E, None, args(1), &g)
.unwrap()
.text,
"Requires Anvil"
);
// 0x5d REQUIRES_AREA: area 1657 is Darnassus.
assert_eq!(
cast_fail_text(0x5D, None, args(1657), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x5D, None, args(1657), &g)
.unwrap()
.text,
"You need to be in Darnassus"
);
// The fallbacks. An id the DBC doesn't name, and a wire word the server never sent, both
// decline the arm and fall through to the strip — never a raw `%s` on screen.
assert_eq!(
cast_fail_text(0x5E, None, args(999_999), &g).unwrap().text,
cast_fail_text(Caster::Player, 0x5E, None, args(999_999), &g)
.unwrap()
.text,
"Requires"
);
assert_eq!(
cast_fail_text(0x5D, None, FailArgs::default(), &g)
cast_fail_text(Caster::Player, 0x5D, None, FailArgs::default(), &g)
.unwrap()
.text,
"You need to be in"
@@ -707,6 +1017,7 @@ mod tests {
};
assert_eq!(
cast_fail_text(
Caster::Player,
0x5E,
Some(&filling),
FailArgs {
+68 -2
View File
@@ -57,15 +57,43 @@ pub(crate) struct CastFail {
pub spell_id: u32,
pub reason: u8,
pub arg: Option<u32>,
/// **Whose** refusal this is — which decides which of the reference's two message tables
/// resolves it. See [`Caster`].
pub caster: Caster,
}
/// Who failed to cast — the one input that picks between the reference's **two** cast-failure
/// message tables (decision 2033).
///
/// It is not a flag on one handler: `SMSG_CAST_FAILED` and `SMSG_PET_CAST_FAILED` land in two
/// separate functions, `0x6e1a00` and `0x6e8eb0`, each with its own reason -> errorId map. Ten of
/// the pet's reasons resolve to something the player's never says, six of them to the
/// `ERR_PET_SPELL_*` catalog rows that exist for no other purpose. Everything else about the two
/// — the first-layer `SPELL_FAILED_*` vocabulary, the argument arms, the strip fallback — is
/// literally the same code, which is why this rides the shared queue instead of forking it.
///
/// [`super::cast_fail::cast_fail_text`] is the reader; nothing downstream of it asks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Caster {
/// Ours — `SMSG_CAST_FAILED` and every client-local refusal we raise for ourselves.
#[default]
Player,
/// The pet's or the charm's — `SMSG_PET_CAST_FAILED` only.
Pet,
}
impl CastFail {
/// A **client-local** refusal — no wire argument (see [`Self::arg`]).
///
/// Always the player's: every local refusal benilla raises is raised on its own cast, and the
/// reference has no client-side path that refuses on the pet's behalf — a pet's refusals are
/// all decided server-side and arrive as `SMSG_PET_CAST_FAILED`.
pub(crate) const fn local(spell_id: u32, reason: u8) -> Self {
Self {
spell_id,
reason,
arg: None,
caster: Caster::Player,
}
}
}
@@ -83,6 +111,21 @@ impl CastErrors {
spell_id,
reason,
arg: Some(arg),
caster: Caster::Player,
});
}
/// Queue the **pet's** wire refusal (`SMSG_PET_CAST_FAILED`). Same queue, same drain, same
/// sink — [`Caster::Pet`] is the whole difference, and it is read once, at the table pick.
///
/// No argument word: `PetCastFailed::AppendBodyTo` writes none, so the reference's own
/// argument arms on this path have nothing to read.
pub(crate) fn push_pet(&mut self, spell_id: u32, reason: u8) {
self.0.push(CastFail {
spell_id,
reason,
arg: None,
caster: Caster::Pet,
});
}
}
@@ -93,6 +136,18 @@ impl CastErrors {
#[derive(Resource, Default)]
pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
/// Taming refusals queued for the UI error line, as the raw `SMSG_PET_TAME_FAILURE` reason byte
/// (decision 2039) — [`MountErrors`]' shape, and here for the same reason: it is a **wire code
/// family**, resolved by a code table, not a client-local key.
///
/// It cannot ride [`UiErrorKeys`] because its message is a *nested* lookup, which is exactly what
/// the reference does: `0x6e6a20` resolves the reason's `PETTAME_*` key through the script VM
/// (`0x703bf0`) and then passes that **string** as `DisplayError(0xee)`'s argText, filling
/// `ERR_TAME_FAILED`'s lone `%s`. [`UiError`]'s `fill_s` is text the raise site already has; here
/// the raise site has only a byte, and the VM is only reachable at the drain.
#[derive(Resource, Default)]
pub(crate) struct PetTameFailures(pub Vec<u8>);
/// Where a client message is SHOWN is **not a decision this crate makes** — it is the `kind` field
/// (`+0x04`) of the reference's message record, and it now arrives from the catalog
/// ([`benilla_ui::messages`], decision 1770) instead of being hand-carried to every call site
@@ -204,10 +259,21 @@ impl Shown {
///
/// A key with no row falls back to [`MsgKind::Error`] and no sound. That cannot arise in the
/// reference (a message is an *index*, so an unknown key is not expressible), so it only ever
/// means benilla named a key the client does not have — and
/// `every_error_key_in_the_source_is_a_catalog_row` is what keeps it unreachable.
/// means benilla named a key the client does not have.
///
/// `every_error_key_in_the_source_is_a_catalog_row` was supposed to keep that unreachable and
/// **could not**: all 465 catalog keys begin `ERR_`, so that walk collects `"ERR_…"` literals
/// — which makes it blind to exactly the mistake it is guarding against, a raise site that
/// named something else. `PET_SPELL_NOPATH` and `SPELL_FAILED_OUT_OF_RANGE` sat on the pet's
/// feedback line for months, took this fallback every time, and no gate could see them
/// (decision 2033). Widening the walk is not possible from the text — `SPELL_FAILED_*` keys
/// are legitimate GlobalStrings lookups elsewhere in this very module — so the tripwire moves
/// to the funnel: this is where a non-row key becomes observable, so it says so.
pub(crate) fn keyed(key: &str, text: String) -> Self {
let record = benilla_ui::messages::by_key(key);
if record.is_none() {
warn!("message {key:?} is not a catalog row — surface and sound are a guess");
}
Self {
record,
kind: record.map_or(MsgKind::Error, |r| r.kind),
+38 -6
View File
@@ -39,8 +39,8 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use super::errors::{first_missing_totem, first_short_reagent, mount_result_key};
use super::weapon_icon::{auto_attack_icon, substitutes_weapon_icon};
use super::{
cast_fail, show_messages, ui_error_text, CastErrors, MessageSink, MountErrors, PlayerActions,
Shown, Spells, UiError, UiErrorKeys, UiErrorTexts,
cast_fail, show_messages, ui_error_text, CastErrors, MessageSink, MountErrors, PetTameFailures,
PlayerActions, Shown, Spells, UiError, UiErrorKeys, UiErrorTexts,
};
/// What an ITEM action shows when its icon cannot be resolved — the reference's own hardcoded
@@ -106,6 +106,7 @@ pub(super) fn feed_actions(
mut actions: ResMut<PlayerActions>,
mut cast_errors: ResMut<CastErrors>,
mut mount_errors: ResMut<MountErrors>,
mut pet_tame_failures: ResMut<PetTameFailures>,
mut ui_error_keys: ResMut<UiErrorKeys>,
mut ui_error_texts: ResMut<UiErrorTexts>,
spells: Option<Res<Spells>>,
@@ -150,8 +151,12 @@ pub(super) fn feed_actions(
.drain(..)
.filter_map(|fail| {
let crate::ui_action::CastFail {
spell_id, reason, ..
spell_id,
reason,
caster,
..
} = fail;
let pet = caster == crate::ui_action::Caster::Pet;
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
@@ -177,7 +182,11 @@ pub(super) fn feed_actions(
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
}
if reason == 0x78 || reason == 0x5c {
// `0x78` TOTEMS / `0x5c` REAGENTS — **the player's arms only**. The pet handler's
// index table (`0x6e93d0`) sends both to its generic arm, so a pet's refusal takes the
// shared passthrough below and never runs the bag walk or the item query (decision
// 2033).
if !pet && (reason == 0x78 || reason == 0x5c) {
let d = d?;
let failing = if reason == 0x78 {
self_store
@@ -220,7 +229,12 @@ pub(super) fn feed_actions(
// is what `0x6e1a00` holds when it calls the formatter — not the errorId-substituted
// message the red line shows. An empty one drops the line rather than printing a
// sentence with a hole, the same rule every other family here follows.
if let Some(display) = d {
// **The pet's refusal is not logged.** `0x6e1a00` calls the log formatter `0x62c360`
// beside its `DisplayError`; `Spell_C::HandlePetCastFailed 0x6e8eb0` calls neither it
// nor the error sound — its entire call set is the two packet readers, `0x496720` and
// the string plumbing. Before decision 2033 a pet's refused Growl printed "You fail to
// cast Growl: ..." in the combat log, attributed to the player.
if let (Some(display), false) = (d, pet) {
// `0x62aff0`: `Attributes` bit 4 marks an ABILITY, which "performs" rather than
// "casts".
const ATTR_IS_ABILITY: u32 = 0x10;
@@ -251,6 +265,7 @@ pub(super) fn feed_actions(
}
}
let text = cast_fail::cast_fail_text(
caster,
reason,
d,
cast_fail::FailArgs {
@@ -265,7 +280,8 @@ pub(super) fn feed_actions(
// Logging the reason, its wire argument and the resolved text makes an argument arm
// that silently declined (a missing word, an unnamed id) legible from a probe run.
debug!(
"ui_action: cast fail — spell {spell_id} reason {reason:#04x} arg {:?} → {:?}",
"ui_action: cast fail — {caster:?} spell {spell_id} reason {reason:#04x} \
arg {:?} → {:?}",
fail.arg, text
);
text
@@ -301,6 +317,22 @@ pub(super) fn feed_actions(
.map(|(key, text)| Shown::keyed(key, text)),
);
// Taming refusals ([`PetTameFailures`]) — the one message whose argText is itself a
// GlobalStrings lookup. The reference resolves the reason's `PETTAME_*` key first
// (`0x6e6a20`'s `0x703bf0` call) and passes the resulting STRING as `DisplayError(0xee)`'s
// argument, so `ERR_TAME_FAILED` ("%s.") renders "Creature is too high level for you to
// tame." — two lookups, in this order, and neither can be folded into the other.
let tame_texts: Vec<Shown> = pet_tame_failures
.0
.drain(..)
.filter_map(|reason| {
let reason_key = benilla_protocol::messages::pet_tame_failure_key(reason);
let reason_text = script.lua().globals().get::<String>(reason_key).ok()?;
super::keyed_line_s(&script, "ERR_TAME_FAILED", &[&reason_text])
})
.collect();
show_messages(&mut script, &mut sink, "ui_action", tame_texts);
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]); the key IS the
// GlobalStrings lookup, no code table between, and the key is also what names the surface:
// [`UiError::kind`] reads the message record straight out of the catalog instead of the queue
+143 -1
View File
@@ -17,7 +17,7 @@ use benilla_ui::script::UiScript;
use bevy::prelude::*;
use super::feed::{feed_actions, MISSING_ITEM_ICON};
use super::{CastErrors, MountErrors, PlayerActions, UiErrorKeys, UiErrorTexts};
use super::{CastErrors, MountErrors, PetTameFailures, PlayerActions, UiErrorKeys, UiErrorTexts};
use crate::entities::ItemDisplays;
use crate::items::{test_template, Items};
use crate::net::{ClientCommand, NetCommands};
@@ -60,6 +60,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
.init_resource::<Items>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
// The cast-failure combat-log line (1703) rides the same drain.
@@ -311,6 +312,7 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
.init_resource::<Items>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
// The cast-failure combat-log line (1703) rides the same drain.
@@ -475,3 +477,143 @@ fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() {
"red UI_ERROR_MESSAGE for the notice, yellow UI_INFO_MESSAGE for the area trigger"
);
}
/// **A pet's refused cast is not written to the combat log** (decision 2033).
///
/// `HandleCastFailed 0x6e1a00` calls the log formatter `0x62c360` beside its `DisplayError`;
/// `HandlePetCastFailed 0x6e8eb0` calls neither it nor the error sound — its whole call set is the
/// two packet readers, `0x496720`, and the string plumbing behind it. Until this guard the drain
/// treated both queue entries alike, so a pet's refused Growl printed "You fail to cast Growl:
/// ..." in the log, worded as the player's own failure.
///
/// Driven through the real system with the two entries **side by side and the same spell**, so the
/// assert cannot pass by the line failing to build for some unrelated reason: one goes in, one
/// comes out.
#[test]
fn a_pets_refused_cast_writes_no_combat_log_line() {
use crate::ui_action::{CastFail, Caster, Spells};
use benilla_formats::{SpellCatalog, SpellDisplay};
const GROWL: u32 = 2649;
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut errors = CastErrors::default();
// 0x5f ROOTED: an override on BOTH tables, so neither entry can be dropped for want of a
// string, and the red line still gets two lines — it is only the log that differs.
errors.0.push(CastFail {
spell_id: GROWL,
reason: 0x5F,
arg: None,
caster: Caster::Player,
});
errors.push_pet(GROWL, 0x5F);
app.insert_resource(PlayerActions::default())
.insert_resource(errors)
.insert_resource(Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
GROWL,
SpellDisplay {
name: "Growl".into(),
..Default::default()
},
)])),
..Spells::empty_for_tests()
})
.init_resource::<Items>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.insert_resource(NetCommands(tx));
let script = UiScript::new().unwrap();
// Only the two strings the log line needs; the red line's own text is `cast_fail`'s business
// and has its own tests.
script
.run(r#"SPELL_FAILED_ROOTED = "You are unable to move";"#)
.unwrap();
app.insert_non_send_resource(script);
app.add_systems(Update, feed_actions);
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_len(),
1,
"the player's failure logs and the pet's does not"
);
}
/// **The taming refusal is TWO GlobalStrings lookups, in order** (decision 2039) — the one
/// message benilla shows whose argText is itself a key.
///
/// The reference's `0x6e6a20` resolves `PETTAME_<reason>` through the script VM first and hands
/// the resulting *string* to `DisplayError(0xee)`, so `ERR_TAME_FAILED` ("%s.") renders the
/// reason's sentence with a period after it. Getting the order wrong is not a compile error and
/// not a silent no-op — it would print the literal key on the red line — so it is pinned here at
/// both ends of the reason table's bounds check.
#[test]
fn a_tame_failure_composes_the_reason_string_into_err_tame_failed() {
let (mut app, _rx) = app_with_food_on_the_bar();
{
let script = app.world_mut().non_send_resource_mut::<UiScript>();
// The two strings verbatim from 1.12's `GlobalStrings.lua`, plus a recorder for the
// event the sink fires — this test is about the composition, not the frame.
script
.run(
r#"
ERR_TAME_FAILED = "%s.";
PETTAME_TOOHIGHLEVEL = "Creature is too high level for you to tame";
PETTAME_UNKNOWNERROR = "Unknown taming error";
BENILLA_TEST_LINES = {};
local f = CreateFrame("Frame")
f:RegisterEvent("UI_ERROR_MESSAGE")
f:SetScript("OnEvent", function()
table.insert(BENILLA_TEST_LINES, arg1)
end)
"#,
)
.unwrap();
}
let line = |app: &mut App, i: usize| {
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Option<String>>(&format!("return BENILLA_TEST_LINES[{i}]"))
.unwrap()
};
// 9 = PETTAME_TOOHIGHLEVEL, the middle of the jump table; 12 is vmangos's own out-of-range
// twelfth value, which the reference's `reason - 1 > 0xa` bound sends to the default arm.
app.world_mut().resource_mut::<PetTameFailures>().0.push(9);
app.world_mut().resource_mut::<PetTameFailures>().0.push(12);
app.update();
assert!(
app.world().resource::<PetTameFailures>().0.is_empty(),
"the feed drains the queue"
);
assert_eq!(
line(&mut app, 1).as_deref(),
Some("Creature is too high level for you to tame."),
"the reason string fills ERR_TAME_FAILED's %s — not the key, and not the reason alone"
);
assert_eq!(
line(&mut app, 2).as_deref(),
Some("Unknown taming error."),
"out of range takes the default arm rather than showing nothing"
);
// The reference's data-suppression face: a reason whose PETTAME string is not loaded shows
// NOTHING rather than a bare "." — the inner lookup fails, so the whole line is suppressed.
app.world_mut().resource_mut::<PetTameFailures>().0.push(1);
app.update();
assert_eq!(
line(&mut app, 3),
None,
"an unresolvable reason shows nothing"
);
}
+3 -2
View File
@@ -67,8 +67,8 @@ pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
attack_actor_blocked, attack_actor_refusal, keyed_line, keyed_line_s, reagent_totem_refusal,
show_messages, ui_error_text, CastErrors, CastFail, MessageSink, MountErrors, Shown, UiError,
UiErrorKeys, UiErrorTexts,
show_messages, ui_error_text, CastErrors, CastFail, Caster, MessageSink, MountErrors,
PetTameFailures, Shown, UiError, UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the requirement validator's mounted block is ONE gate in the reference
// (`0x6094f0` @ `0x609c6c`) sitting under the ONE cast entry `TryCast 0x6e4b60` — but benilla still
@@ -327,6 +327,7 @@ impl Plugin for UiActionPlugin {
.init_resource::<LearnedAbilities>()
.init_resource::<CastErrors>()
.init_resource::<MountErrors>()
.init_resource::<PetTameFailures>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
.init_resource::<crate::cooldowns::Cooldowns>()
+13 -56
View File
@@ -6,19 +6,20 @@
//! at its fixed position ([`LootState::remove_slot`] — the layout never compacts while open);
//! `SMSG_LOOT_CLEAR_MONEY` → the coin row becomes the same kind of gap
//! ([`LootState::clear_money`]); `SMSG_LOOT_RELEASE_RESPONSE` → the window closes
//! ([`LootState::clear`]); the error shape → [`LootErrors`]; `SMSG_ITEM_PUSH_RESULT` → a queued
//! "You receive loot" line ([`LootState::receives`]). A removal that empties the window arms the
//! client-authoritative **auto-close** ([`LootState::auto_release`] — the real engine's
//! close-on-last-slot), released by [`drain_loot`].
//! ([`LootState::clear`]); the error shape → a red line by GlobalStrings key, raised straight from
//! the bridge ([`crate::net::apply::loot::loot_error`] — it needs no queue on this side);
//! `SMSG_ITEM_PUSH_RESULT` → a queued "You receive loot" line ([`LootState::receives`]). A removal
//! that empties the window arms the client-authoritative **auto-close**
//! ([`LootState::auto_release`] — the real engine's close-on-last-slot), released by
//! [`drain_loot`].
//!
//! Each frame [`feed_loot`] surfaces the errors + receive lines on the red UI error line (the
//! equip-error path's exact shape — an ErrorsFrame-style v1 stopgap that migrates to the chat frame
//! next arc), resolves each wire [`LootItem`] to a Lua-facing [`LootRow`] (icon straight from the
//! wire `display_info_id` through the same `ItemDisplayInfo.dbc` catalog the bags use — no template
//! wait; name + quality via the ask-once item-template cache, `None`/re-fed while in flight),
//! prepends the synthesized coin row when the loot carries gold, pushes the snapshot
//! ([`benilla_ui::script::UiScript::set_loot`]), and fires `LOOT_OPENED` on open / `LOOT_UPDATE` on a
//! content change / `LOOT_CLOSED` on clear. [`drain_loot`] pulls the Lua intents back out: `LootSlot`
//! Each frame [`feed_loot`] surfaces the receive lines, resolves each wire [`LootItem`] to a
//! Lua-facing [`LootRow`] (icon straight from the wire `display_info_id` through the same
//! `ItemDisplayInfo.dbc` catalog the bags use — no template wait; name + quality via the ask-once
//! item-template cache, `None`/re-fed while in flight), prepends the synthesized coin row when the
//! loot carries gold, pushes the snapshot ([`benilla_ui::script::UiScript::set_loot`]), and fires
//! `LOOT_OPENED` on open / `LOOT_UPDATE` on a content change / `LOOT_CLOSED` on clear.
//! [`drain_loot`] pulls the Lua intents back out: `LootSlot`
//! → coin ? [`ClientCommand::LootMoney`] : [`ClientCommand::AutostoreLootItem`] (the clicked 1-based
//! row mapped to the item's **wire** loot slot); `CloseLoot` → [`ClientCommand::LootRelease`].
@@ -448,11 +449,6 @@ impl LootState {
}
}
/// A loot refusal (`SMSG_LOOT_RESPONSE`'s error shape) queued by the net bridge for the UI error line
/// — the loot twin of [`crate::ui_merchant::MerchantErrors`]. Carries the wire `u8` `LootError` code.
#[derive(Resource, Default)]
pub(crate) struct LootErrors(pub Vec<u8>);
/// The loot player knob (decision 0961): `autoLootDefault` — era's Controls-page checkbox (no
/// 1.12 CVar exists; vanilla only had the shift-click), settable from the Options window
/// through the CVar store (0954). The reference implements auto-loot ENGINE-side (era's own
@@ -598,7 +594,6 @@ pub(crate) struct UiLootPlugin;
impl Plugin for UiLootPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LootState>()
.init_resource::<LootErrors>()
.init_resource::<LootConfig>()
.init_resource::<LootLatch>()
.init_resource::<LootKneel>()
@@ -618,32 +613,6 @@ impl Plugin for UiLootPlugin {
}
}
/// The client's message string for a `LootError` refusal (`SMSG_LOOT_RESPONSE`'s error shape); values
/// from [`benilla_protocol::messages::loot_error`] (VERIFIED vmangos `LootMgr.h`). Only the
/// subset a plain `CMSG_LOOT` can surface is spelled out; the rest print their code.
fn loot_error_text(reason: u8) -> String {
use benilla_protocol::messages::loot_error as e;
match reason {
e::DIDNT_KILL => "You don't have permission to loot that corpse.".into(),
e::TOO_FAR => "You are too far away to loot that.".into(),
e::BAD_FACING => "You can't loot that from there.".into(),
e::LOCKED => "Someone is already looting that corpse.".into(),
e::NOTSTANDING => "You need to be standing up to loot.".into(),
e::STUNNED => "You can't do that while stunned.".into(),
e::PLAYER_NOT_FOUND => "You can't loot that right now.".into(),
e::ALREADY_PICKPOCKETED => "Those pockets are already empty.".into(),
// The master looter's three refusals (decision 1675). These reach only the master looter,
// in answer to a `CMSG_LOOT_MASTER_GIVE` the server would not honour
// (`LootHandler.cpp:718-729`), and unlike the lines above they are QUOTED from 1.12's own
// GlobalStrings (l.1679-1681) rather than composed — the reference has real strings for
// exactly this trio.
e::MASTER_INV_FULL => "That player's inventory is full".into(),
e::MASTER_UNIQUE_ITEM => "Player has too many of that item already".into(),
e::MASTER_OTHER => "Can't assign item to that player".into(),
other => format!("You can't loot that ({other})."),
}
}
/// Copper → the loot coin-row name text: each nonzero denomination as "<n> <Word>", the words the real
/// GlobalStrings coin words (`GOLD`/`SILVER`/`COPPER`), joined by a single space and dropping leading
/// zeroes (25 → "25 Copper"; 10025 → "1 Gold 25 Copper"; 0 → "0 Copper"). The coin row's name is
@@ -953,7 +922,6 @@ fn feed_loot(
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
mut errors: ResMut<LootErrors>,
mut chat: ResMut<crate::ui_chat::ChatLog>,
mut last: Local<crate::ui_script::VmMemo<Option<LootSnapshot>>>,
cfg: Res<LootConfig>,
@@ -976,15 +944,6 @@ fn feed_loot(
props: props.as_deref(),
enchants: enchants.as_deref(),
};
// Loot refusals + "You receive …" lines migrate to the chat window (decision 0084's chat arc):
// refusals as informational SYSTEM-yellow lines, receive lines as LOOT-green. The ErrorsFrame
// keeps only the cast/equip red toasts.
for reason in errors.0.drain(..) {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
loot_error_text(reason),
));
}
drain_receives(
&mut loot,
&mut items,
@@ -1486,7 +1445,6 @@ mod tests {
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
.init_resource::<LootState>()
.init_resource::<LootErrors>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
.init_resource::<NameCache>()
@@ -1752,7 +1710,6 @@ mod tests {
let mut app = App::new();
app.add_message::<crate::sound::LootPickupSound>()
.init_resource::<LootState>()
.init_resource::<LootErrors>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<GroupState>()
.init_resource::<NameCache>()
@@ -385,11 +385,17 @@ fn the_ghost_predicates() {
..Default::default()
}),
);
assert!(!s.eval::<bool>("return UnitIsDead(\"player\")").unwrap());
assert!(s.eval::<bool>("return UnitIsGhost(\"player\")").unwrap());
// The trio's shape is 1/nil, never a boolean (decision 2043), so these read the value rather
// than its truthiness — `== nil` is the comparison a boolean would invert.
assert!(s
.eval::<bool>("return UnitIsDeadOrGhost(\"player\")")
.eval::<bool>("return UnitIsDead(\"player\") == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return UnitIsGhost(\"player\")").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return UnitIsDeadOrGhost(\"player\")")
.unwrap(),
1
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1807,3 +1807,112 @@ fn share_quest_click_queues_the_selected_quests_id() {
assert!(s.take_quest_log_pushes().is_empty(), "drained");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The reference's title width cap is live, it bites, and it does not wrap the row.**
///
/// Stock `QuestLogFrame.lua:196-203` shrinks a tagged row's title to `275 − 15 − tagWidth` so a
/// long name cannot run under the right-flush `(Elite)`. Decision 1873 declined to implement that
/// cap in our own (now deleted) copy of the window, on the reading that an explicit width is a
/// **wrap** width here and would spill the title onto a second line inside the 16-unit row;
/// 1944 then put the reference's own file on the chain and the cap arrived with it, unrevisited.
///
/// It is correct, and the reading was wrong about our own engine: the title FontString carries a
/// DECLARED height (`<ButtonText …><AbsDimension x="0" y="10"/>`), which arms both overflow
/// regimes — the line stack and the height-gated ellipsis — so the paint is one truncated line,
/// which is the reference's own result (wow-re `fontstring-overflow.md`: the ellipsis gate is
/// `boxW > 0 && boxH > 0`, and only an AUTO-height FontString escapes it). The render half is
/// pinned on the real font in `ui_text::layout`'s
/// `a_capped_quest_log_title_ellipsizes_on_one_line`; this is the geometry half — that the row
/// really hands that pass a box narrower than its own text.
#[test]
fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The cap is arithmetic over `questTitleTag:GetWidth()`, read inside the update — the app's
// measure seam is synchronous (`extract::seat_text_measurer`), so the harness installs its
// stand-in or every tag measures 0 and the cap comes out at the wrong number.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// A real 1.11 Dungeon-tagged quest, and the longest title the tag branch has to survive.
const LONG: &str = "The Left Piece of Lord Valthalak's Amulet";
let mut state = eight_entries();
state.entries[0].title = LONG.into();
state.entries[0].tag = Some("Dungeon".into());
// Row 2 carries the same name with NO tag: the else branch never sets a width at all.
state.entries[1].title = LONG.into();
state.entries[1].tag = None;
s.set_quest_log(state.clone());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let width =
|s: &mut UiScript, q: &str| s.eval::<f32>(&format!("return {q}:GetWidth()")).unwrap();
let ink = |s: &mut UiScript, q: &str| {
s.eval::<f32>(&format!("return {q}:GetStringWidth()"))
.unwrap()
};
// The cap is the reference's own arithmetic, over the tag string it just painted.
let tag_w = width(&mut s, "QuestLogTitle1Tag");
let capped = width(&mut s, "QuestLogTitle1NormalText");
assert!(
(capped - (275.0 - 15.0 - tag_w)).abs() < 0.5,
"the row's title is capped at 275-15-tagWidth ({tag_w} wide): got {capped}"
);
// And it BITES — the box is narrower than the title's own extent, which is the whole case
// 1873 refused. What the paint does with that is the render half's test.
let natural = ink(&mut s, "QuestLogTitle1NormalText");
assert!(natural > capped, "the fixture title ({natural} px) must overflow the {capped} px cap or this test proves nothing");
// The row's title box is one line tall — the declared height that arms both overflow regimes.
// An auto-height FontString would escape the ellipsis gate and stack a second line into a
// 16-unit row, which is exactly the defect that was feared.
assert!(
(s.eval::<f32>("return QuestLogTitle1NormalText:GetHeight()")
.unwrap()
- 10.0)
.abs()
< 0.5,
"the ButtonText's declared height (QuestLogFrame.xml:94-96)"
);
// An untagged row is never capped: the else branch's reset only fires above 275.
assert!(
(width(&mut s, "QuestLogTitle2NormalText") - ink(&mut s, "QuestLogTitle2NormalText")).abs()
< 0.5,
"an untagged title keeps its natural width"
);
// **The cap is sticky per row button, and that is the reference's too**: once the title has an
// explicit width, `questNormalText:GetWidth()` answers it (ours and the client's both), so the
// else branch's `> 275` reset can never fire and a row that has shown a tagged quest keeps the
// narrow box when an untagged one scrolls into it. Reproduced, not a benilla defect — but the
// day our metric reads stop being sticky this row silently changes width, so it is pinned.
state.entries[0].tag = None;
s.set_quest_log(state);
s.run("QuestLog_Update()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
(width(&mut s, "QuestLogTitle1NormalText") - capped).abs() < 0.5,
"the capped box survives the row going untagged (the ref's own 275 reset never fires)"
);
}
@@ -157,6 +157,53 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The title a world hover paints while the creature query is still in flight**, over the
/// shipped files: `UNKNOWNOBJECT` as `GlobalStrings.lua` defines it on the PLAYER'S OWN CHAIN.
/// Read out of the VM at the assert rather than written as a literal, because that is the whole
/// point of the resolver's `0x703bf0` read — a translated GlobalStrings translates the
/// placeholder too (decision 2040, closing 2002's residue).
///
/// The engine half (the miss legs, the empty-global fallback, the `"player"` case) is
/// `benilla_ui`'s own `tooltip_unit` suite; what this adds is the chain: the string really is
/// defined, the plate really reads it, and the answer really replaces it.
#[test]
fn a_pending_name_hover_titles_the_chains_unknownobject() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(&["Interface\\FrameXML\\GlobalStrings.lua"]);
// The snapshot the feed pushes before `SMSG_CREATURE_QUERY_RESPONSE` lands: the descriptor is
// in, and the name and the type word — which ride the same record — are not.
s.set_unit(
"mouseover",
Some(UnitState {
name: None,
creature_type_name: None,
..wolf()
}),
);
assert!(s.world_tooltip_unit("mouseover"), "the hover shows");
let global = s.eval::<String>("return UNKNOWNOBJECT").unwrap();
assert!(
!global.is_empty(),
"the chain's GlobalStrings.lua defines UNKNOWNOBJECT"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
global,
"a name in flight titles the plate with the GlobalString, not an empty line"
);
// The query answers; the next paint of the same hover carries the real name.
s.set_unit("mouseover", Some(wolf()));
assert!(s.world_tooltip_unit("mouseover"));
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Timber Wolf"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The detailed-tooltip fork (ref UnitFrame_OnEnter l.58-67, director-approved 0663): with tips on
/// — the 1.12 default — the frame explains its RIGHT-CLICK MENU and returns BEFORE `SetUnit`, so
/// the unit lines never render. Your own portrait always; another player's whenever they're your
@@ -1417,9 +1417,10 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
"the WORD is the GlobalString `DEAD` (l.898), never the key: a literal \"DEAD\" here \
is the caps bug the director caught on Onyxia"
);
assert!(
s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap(),
"UnitIsDead 0x517ac0's dynflag leg reaches the API too"
assert_eq!(
s.eval::<i64>(r#"return UnitIsDead("target")"#).unwrap(),
1,
"UnitIsDead 0x517ac0's dynflag leg reaches the API too — as the number 1 (2043)"
);
// He stands back up: the flag clears, and nothing about the body needed restoring.
@@ -586,6 +586,80 @@ mod measure_fits_render {
}
}
/// **A capped quest-log row title ellipsizes on ONE line — it never wraps into the row.**
///
/// Stock `QuestLogFrame.lua:196-203` shrinks a tagged row's title to `275 − 15 − tagWidth` so
/// it cannot run under the right-flush `(Elite)`. Decision 1873 declined that cap on the
/// reading that an explicit width is a **wrap** width here, so a long title would spill onto a
/// second line inside the 16-unit row; 1944 then put the reference's own file on the chain and
/// the cap went live with it. The reading was wrong about our own engine, and this pins why:
/// the title FontString carries a DECLARED height (`<ButtonText>` `y="10"`), which arms both
/// overflow regimes — the ellipsis gate (`boxW > 0 && boxH > 0`, wow-re
/// `fontstring-overflow.md`; [`measure::ellipsize_to_fit`]) and the line stack
/// ([`overflow::lines_allowed`]) — and each on its own holds the paint to one line. The
/// reference does exactly this: its gate holds on the same declared height and its display
/// string is `prefix + "..."`.
///
/// The geometry is the reference's own: row `300×16`, title `GameFontNormal` (FRIZQT__ 12) in a
/// `y="10"` ButtonText (`QuestLogFrame.xml:5-7, 92-103`).
#[test]
fn a_capped_quest_log_title_ellipsizes_on_one_line() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
// GameFontNormal: FRIZQT__ at 12 (Fonts.xml l.70-75). The row's title box is 10 tall.
let s = spec(12.0);
let r = measure::resolve(&mut e, &s);
const BOX_H: f32 = 10.0;
// The longest tag `QuestInfo.dbc` ships is "World Event"; "(Dungeon)" is the longest one a
// quest actually carries in bulk (411 quests, decision 1873's census). Either way the cap
// is `275 - 15 - tagWidth`.
for tag in ["(Elite)", "(Dungeon)", "(World Event)"] {
let cap = 275.0 - 15.0 - measure_text(&mut e, tag, None, s).0;
for title in [
// A real 1.11 Dungeon-tagged quest, indented as the row indents it.
" The Left Piece of Lord Valthalak's Amulet",
" The Right Piece of Lord Valthalak's Amulet",
" Bring Me The Head of Nekrum Gutchewer!",
] {
e.ensure_metrics(r.face, r.ppem, title);
e.ensure_metrics(r.face, r.ppem, "...");
// The cap bites: uncapped the title is one line, capped it needs more than one.
assert_eq!(
measure::wrapped_rows_for_test(&e, r, title, 275.0),
1,
"{title:?} fits the uncapped row"
);
assert!(
measure::wrapped_rows_for_test(&e, r, title, cap) > 1,
"{title:?} must overflow the {cap}px cap for this test to mean anything"
);
// Regime 2, the line stack: a 10-tall box emits ONE line whatever the wrap says.
assert_eq!(
overflow::lines_allowed(BOX_H, r.size),
1,
"the row's 10-tall title box stacks exactly one line"
);
// Regime 3, the ellipsis: the drawn string is a prefix + "..." that fits that line.
let cap_rows = overflow::lines_fitting(BOX_H.max(r.size), r.size) + 1;
let display = overflow::ellipsize_in_box(title, BOX_H, r.size, |candidate| {
measure::wrapped_rows_capped_for_test(&e, r, candidate, cap, cap_rows)
})
.unwrap_or_else(|| panic!("{title:?} at cap {cap} must ellipsize"));
assert!(
display.ends_with("..."),
"the truncation marker is three dots, got {display:?}"
);
assert_eq!(
measure::wrapped_rows_for_test(&e, r, &display, cap),
1,
"{display:?} must fit the row's single line at cap {cap}"
);
}
}
}
/// **One line is ONE baseline — at every DPI, size and seat.**
///
/// The emit pass's whole claim (decision 1342) is that a line is flat *by construction*: one
+124 -20
View File
@@ -1008,7 +1008,12 @@ pub(crate) fn enrich_unit(
let Some(entry) = benilla_protocol::guid::entry(guid) else {
return;
};
if let Some(rec) = names.creature_record(entry) {
// The ask-once creature record — `CGUnit+0xb30`, filled from `SMSG_CREATURE_QUERY_RESPONSE`.
// `None` is the round trip before the answer lands, and it is a REAL state the plate is drawn
// in, not a "nothing is known yet" to render blank: the name reads `UNKNOWNOBJECT` there
// (decision 2040), and the lines below split on exactly what the record does or does not gate.
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, both gates (`gated_rank`, decision 0782) — never `rec.rank`
@@ -1017,25 +1022,31 @@ pub(crate) fn enrich_unit(
state.rank = crate::names::gated_rank(Some(rec), Some(store));
state.civilian = rec.civilian;
state.racial_leader = rec.racial_leader;
// The faction-name line ("Stormwind", between level and PvP) — the unit builder's tail
// block, every gate transcribed: the record's HIDE_FACTION_TOOLTIP type flag (0x10, the
// `0x612610` gate), the template → Faction.dbc hop, the reputation-slot gate
// (rep_index ≥ 0), and the race/class slot walk with its hidden flag (0x4). The record
// gate also stands in for the bytes' "no creature info → pass": before the query
// answers we have no name line either, and the tooltip rebuilds when it lands.
if rec.type_flags & 0x10 == 0 {
state.faction_name = (|| {
let catalog = factions?.catalog();
let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction;
let info = catalog.reputation_faction(faction_id)?;
let self_store = self_store?;
let race = self_store.0.unit_race().unwrap_or(0);
let class = self_store.0.unit_class().unwrap_or(0);
info.tooltip_shows_for(race, class)
.then(|| catalog.faction_name(faction_id).map(str::to_string))
.flatten()
})();
}
}
// The faction-name line ("Stormwind", between level and PvP) — the unit builder's tail block,
// every gate transcribed: the record's HIDE_FACTION_TOOLTIP type flag (0x10), the template →
// Faction.dbc hop, the reputation-slot gate (rep_index ≥ 0), and the race/class slot walk with
// its hidden flag (0x4).
//
// **Its entry gate is NOT the record.** `0x612610` reads `[unit+0xb30]` and returns 1 when
// there is none — that is how a PLAYER passes a gate whose only field lives in CreatureInfo,
// and a creature whose query has not answered takes the identical leg. So the line resolves
// off the DESCRIPTOR's `UNIT_FIELD_FACTIONTEMPLATE`, which is already streamed, and shows
// under a pending name exactly as it does under a known one. It was gated on the record here
// on the premise that "before the query answers we have no name line either" — the premise
// decision 2002 corrected, and 2040 with it.
if rec.is_none_or(|r| r.type_flags & 0x10 == 0) {
state.faction_name = (|| {
let catalog = factions?.catalog();
let faction_id = catalog.template(store.0.unit_faction_template()?)?.faction;
let info = catalog.reputation_faction(faction_id)?;
let self_store = self_store?;
let race = self_store.0.unit_race().unwrap_or(0);
let class = self_store.0.unit_class().unwrap_or(0);
info.tooltip_shows_for(race, class)
.then(|| catalog.faction_name(faction_id).map(str::to_string))
.flatten()
})();
}
}
@@ -2484,6 +2495,99 @@ mod tests {
assert_eq!(s.rank, 0, "an un-queried creature has no classification");
}
/// **The faction-name line does not wait for the creature query** (decision 2040).
///
/// Its entry gate `0x612610` reads `[unit+0xb30]` and **returns 1 when there is none** — the
/// leg a PLAYER takes through a gate whose only field lives in CreatureInfo, and the leg a
/// creature takes for the round trip before `SMSG_CREATURE_QUERY_RESPONSE` lands. Everything
/// the line itself needs is on the descriptor (`UNIT_FIELD_FACTIONTEMPLATE`) and already
/// streamed, so it shows under the pending `UNKNOWNOBJECT` title rather than arriving a round
/// trip after it. It was gated on the record here, on the premise that a pending creature had
/// no name line either — the premise decision 2002 corrected.
///
/// The record still owns the line's ONE creature-side gate, `HIDE_FACTION_TOOLTIP` (type flag
/// `0x10`), which is why the answer landing can take the line away again — the reference's
/// own sequence, not a flicker of ours.
#[test]
fn the_faction_line_does_not_wait_for_the_creature_query() {
use benilla_protocol::messages::ObjectFields;
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_faction_catalog(&mut chain).expect("Faction.dbc");
let factions = crate::target::Factions::from_catalog(catalog);
/// `UNIT_FIELD_FACTIONTEMPLATE` / `UNIT_FIELD_BYTES_0` — absolute descriptor indices.
const FACTIONTEMPLATE: u16 = 35;
const BYTES_0: u16 = 36;
/// The local player the slot walk is matched against: race 1 (human), class 1 (warrior),
/// packed as `UNIT_FIELD_BYTES_0` bytes 0 and 1.
const HUMAN_WARRIOR: u32 = 1 | (1 << 8);
/// A creature entry the cache below is deliberately never told about.
const ENTRY: u32 = 299;
const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x7;
let me = ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, HUMAN_WARRIOR)]));
// The first template id whose faction carries a reputation slot this character can see.
// Derived from the real DBC rather than guessed, so the test names no id it cannot justify.
let (template_id, expected) = (1u32..3000)
.find_map(|id| {
let f = factions.catalog().template(id)?.faction;
let info = factions.catalog().reputation_faction(f)?;
info.tooltip_shows_for(1, 1)
.then(|| factions.catalog().faction_name(f))
.flatten()
.map(|n| (id, n.to_string()))
})
.expect("some faction template shows a tooltip line to a human warrior");
let store = ObjectStore(ObjectFields::from_pairs(&[(FACTIONTEMPLATE, template_id)]));
let line_for = |names: &NameCache| {
let mut state = UnitState::default();
enrich_unit(&mut state, GUID, names, &store, Some(&factions), Some(&me));
state
};
// The query is still in flight: no record, so no name, no subtitle and no rank — and the
// faction line all the same.
let pending = line_for(&NameCache::default());
assert_eq!(pending.name, None, "the name is the thing still in flight");
assert_eq!(pending.subtitle, None);
assert_eq!(
pending.faction_name.as_deref(),
Some(expected.as_str()),
"the faction line resolves off the descriptor alone"
);
let record = |type_flags: u32| crate::names::CreatureRecord {
name: "Stormwind Guard".into(),
subname: None,
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags,
civilian: false,
racial_leader: false,
display_id: 0,
};
let mut answered = NameCache::default();
answered.insert_creature(ENTRY, Some(record(0)));
assert_eq!(
line_for(&answered).faction_name.as_deref(),
Some(expected.as_str()),
"the answer landing keeps the line it was already showing"
);
// The one creature-side gate the record does own.
let mut hidden = NameCache::default();
hidden.insert_creature(ENTRY, Some(record(0x10)));
assert_eq!(
line_for(&hidden).faction_name,
None,
"HIDE_FACTION_TOOLTIP takes the line away once the record says so"
);
}
/// The PvP-preference announcement law (decision 0652), as the reference's changed-bits handler
/// runs it: silent on first sight, one pair per real edge, and the OFF text is the one that
/// explains the five-minute wait — the whole reason the toggle doesn't read as dead.
+54 -4
View File
@@ -95,10 +95,21 @@ fn every_error_key_in_the_source_is_a_catalog_row() {
#[test]
fn every_error_key_in_the_source_resolves_to_real_text() {
/// Keys whose row the shipped `GlobalStrings.lua` has **no string for**, so the reference
/// itself shows nothing when it raises them. Both are documented where they are raised —
/// `ui_items::equip_error` (errorId 362) and `ui_action::cast_fail` (the pet-happiness arm).
/// A third entry here is a defect until someone proves otherwise.
const SILENT_IN_5875: &[&str] = &["ERR_CANT_BE_DISENCHANTED", "ERR_NOT_HAPPY_ENOUGH"];
/// itself shows nothing when it raises them. Each is documented where it is raised —
/// `ui_items::equip_error` (errorId 362), `ui_action::cast_fail` (the pet-happiness arm), and
/// `net::apply::pet` (errorId 337, both pet handlers' NOPATH arm). A fourth entry here is a
/// defect until someone proves otherwise.
///
/// `ERR_PET_SPELL_NOPATH` is the one worth reading twice, because it is a trap: 5875 *does*
/// ship a `PET_SPELL_NOPATH` ("No path available for your pet"), one letter-group away and
/// perfectly plausible on screen — but it is not row 337's key and no code path in the client
/// raises it. benilla named it for months and showed the wrong string for the wrong byte
/// (decision 2033). Silence here is the reference's behaviour, not a gap.
const SILENT_IN_5875: &[&str] = &[
"ERR_CANT_BE_DISENCHANTED",
"ERR_NOT_HAPPY_ENOUGH",
"ERR_PET_SPELL_NOPATH",
];
let data = match benilla_formats::wow_data() {
Some(d) => d,
@@ -268,3 +279,42 @@ fn every_sounding_catalog_row_also_has_text_to_show() {
}
assert_eq!(sounding, 86, "56 voice lines + 30 named cues");
}
/// **The non-`ERR_` half of the same tripwire, for the one family that has one** (decision 2039):
/// every string `pet_tame_failure_key` can return must be a real global of the 1.12 client.
///
/// The walk above collects `"ERR_…"` literals, which is every catalog row — and is therefore
/// blind to a raise site that names something else. `SMSG_PET_TAME_FAILURE` is precisely that
/// shape: the message it raises is `ERR_TAME_FAILED`, but the *text* comes from a second lookup
/// of a `PETTAME_*` key, and an invented one there would fail exactly the way this file's header
/// describes — silently, with no line shown and nobody to file it.
///
/// Checked against `reference/1.12-globals.tsv`, the running client's own `_G` (decision 1189),
/// rather than against the install: it is in-tree, so this holds on a machine with no client.
#[test]
fn every_pettame_key_is_a_real_1_12_global() {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
);
let text = std::fs::read_to_string(tsv).expect("the reference surface");
let globals: BTreeSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
// The whole reachable image of the table: 1..=11 index it, everything else is the default
// arm. `u8` is small enough to just walk.
let reachable: BTreeSet<&str> = (0..=u8::MAX)
.map(benilla_protocol::messages::pet_tame_failure_key)
.collect();
assert_eq!(reachable.len(), 12, "eleven arms plus the default");
for key in &reachable {
assert!(globals.contains(key), "{key} is not a 1.12 global");
}
// The message the fill goes into, and the two bodiless arms beside it.
for key in ["ERR_TAME_FAILED", "ERR_INVALID_PETNAME", "ERR_PET_BROKEN"] {
assert!(globals.contains(key), "{key} is not a 1.12 global");
}
}
+134 -1
View File
@@ -7,6 +7,13 @@
//! FootstepTerrainLookup **class**, NOT a kit), AggroID, WingFlapID, WingGlideID, AlertID,
//! Fidget[4], CustomAttack[4], NPCSoundID, LoopSoundID, CreatureImpactType (0 flesh · 1 stone ·
//! 2 wood · 3 ethereal), JumpStartID, JumpEndID, PetAttackID, PetOrderID, PetDismissID`.
//! Columns **27/28/29** are the pet's own voice (decision 2039): 27 and 28 are what the bark
//! dispatcher `0x623a40` reaches at states 2 and 1 — `SMSG_PET_ACTION_SOUND`'s two selectors —
//! and 29 is `SMSG_PET_DISMISS_SOUND`'s, resolved fresh by model id at `0x604140` rather than off
//! the cached row, which is why a census over the cache's consumers missed it. The **names** here
//! are the community's and stay INFERRED — a DBC carries no column names — but the shipped kits
//! corroborate them: the four rows that populate 27/28 name `A_<demon>_KILL` and `A_<demon>_ORDER`.
//! The column nothing reads is **22**, which is also empty on all 406 rows.
//! Spot-check row 26: exertion 312/313, injury 315/316/0, death 314, stun 690, stand 317,
//! footstep class 8, aggro 694, alert 1107 — all coherent kit-id ranges.
//! The chain is `UNIT_FIELD_DISPLAYID` → `CreatureDisplayInfo.SoundID`, **falling back to
@@ -54,11 +61,27 @@ pub struct CreatureVoice {
pub impact_type: u32,
pub jump_start: u32,
pub jump_end: u32,
/// **Column 27** — the bark a pet gives when it takes an attack order
/// (`SMSG_PET_ACTION_SOUND` selector `PET_TALK_ATTACK`, the reference's bark state **2**,
/// read at `0x623ad3` as `[row+0x6c]`).
pub pet_attack: u32,
/// **Column 28** — the bark a pet gives when it acknowledges an ordered spell
/// (`SMSG_PET_ACTION_SOUND` selector `PET_TALK_SPECIAL_SPELL`, the reference's bark state
/// **1**, read at `0x623ac8` as `[row+0x70]`).
pub pet_order: u32,
/// **Column 29** — what a dismissed pet says as it goes (`SMSG_PET_DISMISS_SOUND`'s handler
/// `0x604140`, read at `0x6041c4`/`0x6041dc` as `[row+0x74]`).
///
/// Not a bark: it is off `0x623a40`'s table entirely, played at a bare world position with no
/// unit, no latch and no attach point — because by the time it sounds the pet is gone.
pub pet_dismiss: u32,
}
/// display id → voice rows, joined through `CreatureDisplayInfo.SoundID`.
/// display id → voice rows, joined through `CreatureDisplayInfo.SoundID`, plus the raw
/// `CreatureModelData.SoundID` join the dismiss sound needs on its own.
pub struct CreatureVoiceCatalog {
display_to_sound: HashMap<u32, u32>,
model_to_sound: HashMap<u32, u32>,
rows: HashMap<u32, CreatureVoice>,
}
@@ -68,6 +91,17 @@ impl CreatureVoiceCatalog {
self.rows.get(self.display_to_sound.get(&display_id)?)
}
/// The voice set for a **`CreatureModelData` id**, through that table's own `SoundID`
/// (col 13) with no display in between.
///
/// The odd one out, and deliberately so: every other caller starts from
/// `UNIT_FIELD_DISPLAYID`, but `SMSG_PET_DISMISS_SOUND` names a model directly and the
/// reference resolves it exactly this way (`0x604140`: `[[0xc0de68] + id*4] + 0x34` →
/// `[[0xc0de54] + sound*4]`) — no display step, no fallback.
pub fn for_model(&self, model_id: u32) -> Option<&CreatureVoice> {
self.rows.get(self.model_to_sound.get(&model_id)?)
}
pub fn len(&self) -> usize {
self.rows.len()
}
@@ -130,6 +164,9 @@ pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCat
impact_type: g(24),
jump_start: g(25),
jump_end: g(26),
pet_attack: g(27),
pet_order: g(28),
pet_dismiss: g(29),
},
);
}
@@ -169,6 +206,7 @@ pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCat
}
Ok(CreatureVoiceCatalog {
display_to_sound,
model_to_sound,
rows,
})
}
@@ -202,4 +240,99 @@ mod tests {
.expect("human male resolves via the model");
assert_eq!(human.footstep_class, 7);
}
/// **The pet voice columns are a warlock-demon surface, and the shipped data names them.**
///
/// Exactly **four** of the 406 rows carry columns 27/28/29 — the Imp, Succubus, Doomguard and
/// Voidwalker voice rows — and the kits they name are `A_<demon>_KILL`, `A_<demon>_ORDER` and
/// `A_<demon>_DISMISS`. That naming is an *independent* confirmation of which column is
/// which: the reference sends `PET_TALK_ATTACK` to bark state 2 = column 27,
/// `PET_TALK_SPECIAL_SPELL` to state 1 = column 28, and `SMSG_PET_DISMISS_SOUND` to column 29
/// — and the data calls those three `_KILL`, `_ORDER` and `_DISMISS`, in that order, three
/// times over (decision 2039; it closes wow-re's open question about the names,
/// `feign-death-dyndead.md` §12).
///
/// **A hunter pet is therefore silent on all three, faithfully** — its row's columns are 0,
/// so the reference reads a zero kit and plays nothing. The Felhunter has no kit in the file
/// at all. Skips without client data.
#[test]
fn only_the_four_demon_voices_carry_pet_barks() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_voice_catalog(&mut chain).expect("load creature voices");
let kits = crate::load_sound_kit_catalog(&mut chain).expect("load SoundEntries");
let named = |id: u32| kits.get(id).map(|k| k.name.clone()).unwrap_or_default();
let mut carrying: Vec<(u32, String, String, String)> = cat
.rows
.iter()
.filter(|(_, v)| v.pet_attack != 0 || v.pet_order != 0 || v.pet_dismiss != 0)
.map(|(id, v)| {
(
*id,
named(v.pet_attack),
named(v.pet_order),
named(v.pet_dismiss),
)
})
.collect();
carrying.sort_unstable();
// Casing is the file's own — `A_Imp_Dismiss` and `A_DOOMGUARD_DISMISS01` are how those two
// rows are written, and the lookup is case-insensitive anyway.
assert_eq!(
carrying,
vec![
(
11,
"A_IMP_KILL".into(),
"A_IMP_ORDER".into(),
"A_Imp_Dismiss".into()
),
(
37,
"A_SUCCUBUS_KILL".into(),
"A_SUCCUBUS_ORDER".into(),
"A_SUCCUBUS_DISMISS".into()
),
(
68,
"A_DOOMGUARD_KILL".into(),
"A_DOOMGUARD_ORDER".into(),
"A_DOOMGUARD_DISMISS01".into()
),
(
162,
"A_VOIDWALKER_KILL".into(),
"A_VOIDWALKER_ORDER".into(),
"A_VOIDWALKER_DISMISS".into()
),
],
);
// Reachable from real displays, not orphan rows: the Imp's voice is the one 25 of them
// share, and the wolf a hunter tames carries none of the three.
let imp = cat.for_display(904).expect("an imp display resolves");
assert_eq!(
(imp.pet_attack, imp.pet_order, imp.pet_dismiss),
(9097, 9098, 9096)
);
let wolf = cat.for_display(903).expect("the Elwynn wolf resolves");
assert_eq!(
(wolf.pet_attack, wolf.pet_order, wolf.pet_dismiss),
(0, 0, 0)
);
// The dismiss sound's own join — `CreatureModelData.SoundID` with **no display step**
// (`0x604140`). The imp's model must reach the same row its displays do, or the packet
// would be silent for the one pet it exists for.
let by_model = cat
.for_model(
*cat.model_to_sound
.iter()
.find(|(_, s)| **s == 11)
.expect("a model resolves the imp voice")
.0,
)
.expect("model join");
assert_eq!(by_model.pet_dismiss, 9096);
}
}
+16
View File
@@ -681,6 +681,22 @@ pub enum SessionEvent {
/// The pet's cast refusal (`SMSG_PET_CAST_FAILED`) — [`Self::CastResult`]'s vocabulary, but
/// the caster is the pet, so it never touches OUR cast state.
PetCastFailed { spell_id: u32, reason: Option<u8> },
/// A refused tame / Call Pet / Revive Pet (`SMSG_PET_TAME_FAILURE`): one
/// `PetTameFailureReason` byte, whose text is a `PETTAME_*` GlobalStrings key filling
/// `ERR_TAME_FAILED`.
PetTameFailure { reason: u8 },
/// A refused pet rename (`SMSG_PET_NAME_INVALID`) — no payload; the reference raises
/// `ERR_INVALID_PETNAME` on the strength of the opcode alone.
PetNameInvalid,
/// The pet's loyalty hit zero and it ran away (`SMSG_PET_BROKEN`) — no payload;
/// `ERR_PET_BROKEN`.
PetBroken,
/// The pet's voice (`SMSG_PET_ACTION_SOUND`) — one of two talk selectors on a named unit,
/// resolved against that unit's own `CreatureSoundData` row.
PetActionSound { pet_guid: u64, talk: u32 },
/// A dismissed pet's parting sound (`SMSG_PET_DISMISS_SOUND`) — a `CreatureModelData` id and
/// the raw WoW point to play its column-29 kit at. No guid, because the pet has gone.
PetDismissSound { model_id: u32, position: [f32; 3] },
/// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
/// `CMSG_ITEM_QUERY_SINGLE`). Keyed by template entry; `None` = the server doesn't know it
/// (undiscovered) — cached negative, like an unknown creature entry. Boxed for the same reason
@@ -136,6 +136,18 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
CastOutcome::Failed { reason, .. } => Some(reason),
},
}],
ServerPacket::PetTameFailure { reason } => vec![SessionEvent::PetTameFailure { reason }],
ServerPacket::PetNameInvalid => vec![SessionEvent::PetNameInvalid],
ServerPacket::PetBroken => vec![SessionEvent::PetBroken],
ServerPacket::PetActionSound { pet_guid, talk } => {
vec![SessionEvent::PetActionSound { pet_guid, talk }]
}
ServerPacket::PetDismissSound { model_id, position } => {
vec![SessionEvent::PetDismissSound {
model_id,
position: [position.x, position.y, position.z],
}]
}
ServerPacket::ItemQueryResponse { entry, info } => {
vec![SessionEvent::ItemTemplate { entry, info }]
}
+7 -6
View File
@@ -185,12 +185,13 @@ pub use page_text::page_text_query;
pub use parse::parse_server;
pub use pet::{
pet_abandon, pet_action, pet_cancel_aura, pet_rename, pet_set_action, pet_spell_autocast,
pet_stop_attack, pet_unlearn, PetActionEntry, PetMode, PetSpellCooldown, PetSpells,
PetUnlearnConfirm, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED,
PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON, PET_COMMAND_ATTACK,
PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY, PET_COOLDOWN_PERMANENT,
PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE, PET_STATE_BAR_DISABLED,
PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
pet_stop_attack, pet_tame_failure_key, pet_unlearn, PetActionEntry, PetMode, PetSpellCooldown,
PetSpells, PetUnlearnConfirm, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED,
PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON,
PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
PET_COOLDOWN_PERMANENT, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
PET_STATE_BAR_DISABLED, PET_TALK_ATTACK, PET_TALK_ORDER, PET_TYPE_SPELL_FIRST,
PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
pub use petition::{
offer_petition, petition_buy, petition_decline, petition_query, petition_rename,
+63 -5
View File
@@ -210,6 +210,65 @@ pub const SMSG_PET_ACTION_FEEDBACK: u16 = 0x02C6; // 710
/// because the caster that failed is the pet, not us.
pub const SMSG_PET_CAST_FAILED: u16 = 0x0138; // 312
// The three remaining pet-feedback arms, carved together (decision 2039). Each was name-table-only
// until then, and each has a handler in the reference that does something visible.
/// **The taming refusal** — one `u8` reason from `PetTameFailureReason`. Not only taming: vmangos
/// sends it for Call Pet and Revive Pet too (`SpellEffects.cpp:3167/3174`, `Spell.cpp:5463/6113`).
///
/// The reference's handler `0x6e97e0` reads the byte and hands it to `0x6e6a20`, which turns
/// `reason - 1` into one of eleven `PETTAME_*` GlobalStrings keys through the jump table at
/// `0x6e6ac0`, resolves it (`0x703bf0`) and raises `DisplayError(0xee)` =
/// `ERR_TAME_FAILED` (`"%s."`) with that string as the fill. Anything outside `1..=11` — `0` and
/// `12` included — takes the default arm's `PETTAME_UNKNOWNERROR`. The map is in
/// [`super::pet::pet_tame_failure_key`].
pub const SMSG_PET_TAME_FAILURE: u16 = 0x0173; // 371
/// **The refused rename** — an EMPTY body, and a visible error all the same.
///
/// vmangos's `SendPetNameInvalid` drops both the reason code and the name with the comment "not
/// read by vanilla client" (`PetHandler.cpp:542-548`), and the reference agrees: its arm reads no
/// body. What it does read is the *opcode* — 0x178 lands in the shared dispatcher `0x5e38c0`,
/// whose index byte at `0x5e4c6c[0]` selects target `0x5e4c40[0]` = `0x5e3e33`, five instructions:
/// `push 0xf7; call 0x496720` = `DisplayError(247)` = **`ERR_INVALID_PETNAME`**, "Error, invalid
/// name entered.".
///
/// **Decision 1066 said the opposite** — that a refused rename "silently does nothing, which is
/// what the reference does too" — on a carve that had attributed [`SMSG_PET_BROKEN`]'s handler
/// `0x4bdc00` to this opcode. `re/net/opcode-handlers.tsv` maps 0x178 to `0x5e38c0` and 0x2AF to
/// `0x4bdc00`; they are different functions raising different messages.
pub const SMSG_PET_NAME_INVALID: u16 = 0x0178; // 376
/// **The pet ran away** — an empty body; vmangos sends it when a hunter pet's loyalty hits zero
/// (`Pet.cpp:822`, immediately before `Unsummon(PET_SAVE_AS_DELETED)`).
///
/// Handler `0x4bdc00`: `push 0x1a3; call 0x496720` = `DisplayError(419)` = `ERR_PET_BROKEN`,
/// "Your pet has run away". It reads no body, writes no state and fires no event — the bar's
/// teardown rides the `SMSG_PET_SPELLS` zero-guid form as usual (wow-re
/// `ui/scratch/pet-action-bar-api.md` §11c.5).
pub const SMSG_PET_BROKEN: u16 = 0x02AF; // 687
/// **The pet's voice** — `u64 petGuid` then a `u32` *talk* selector (not a `SoundEntries` id):
/// `0` = `PET_TALK_SPECIAL_SPELL`, `1` = `PET_TALK_ATTACK` (vmangos `Pet.h:98`,
/// `Unit::SendPetTalk`).
///
/// Handler `0x6040c0` resolves the guid, then calls the creature bark dispatcher
/// `0x623a40(unit, state)` with **state 1 for selector 0 and state 2 for selector 1** — i.e.
/// `CreatureSoundData` columns 28 (`PetOrder`) and 27 (`PetAttack`). Any other selector plays
/// nothing. The bark rides the unit's ordinary one-shot voice slot and its priority latch, so it
/// is not a free-standing sound (see `crate::sound::creature`'s bark core on the benilla side).
pub const SMSG_PET_ACTION_SOUND: u16 = 0x0324; // 804
/// **The dismissed pet's parting sound** — `u32 creatureModelDataId` then a `f32` x/y/z, and the
/// odd one of the family in every way.
///
/// Handler `0x604140` reads those four fields, walks **`CreatureModelData[id]` → its own
/// `SoundID` (col 13, `[row+0x34]`) → `CreatureSoundData` → column 29**, and plays that kit at the
/// given point with `z + 1.0` (`0x7ff9d8`) and a free-picked variation. No unit is involved: it is
/// off the bark dispatcher's table entirely, takes no voice latch and has no attach point —
/// because by the time it sounds the pet has gone. That fresh, inlined, by-id resolve is also why
/// a census over the *cached* `[unit+0xb40]` row's consumers concluded column 29 was dead.
///
/// **vmangos never sends it** (no `SendPetDismissSound` anywhere in the tree; the opcode is
/// registered only as server-bound). Built because the reference is the spec and this handler is
/// what makes column 29 load-bearing — not because anything we talk to can trigger it.
pub const SMSG_PET_DISMISS_SOUND: u16 = 0x0325; // 805
/// The client's ack that a server-authored spline (`SMSG_MONSTER_MOVE` to our own guid — Charge,
/// knockback, taxi) finished. Body: a `MovementInfo` at the endpoint, the `splineId` being acked,
/// and a trailing float the server `read_skip`s (VERIFIED vmangos `Opcodes_1_12_1.h`: 713;
@@ -408,11 +467,10 @@ pub const CMSG_PET_ABANDON: u16 = 0x0176; // 374
/// disappears after the first rename (`PetHandler.cpp:302-345`). Nothing client-side clears it —
/// wow-re's census found no writer of that byte anywhere in `.text`.
///
/// A refused name answers with `SMSG_PET_NAME_INVALID` (0x178), which is **not modelled** and does
/// not need to be: vmangos sends it with an empty body — `SendPetNameInvalid` drops both the reason
/// code and the name with the comment "not read by vanilla client" (`PetHandler.cpp:542-548`) — and
/// the shipped 1.12 `FrameXML` has no event and no string for it. It lands in `ServerPacket::Other`
/// under its own name, which is the whole of what there is to do with it.
/// A refused name answers with [`SMSG_PET_NAME_INVALID`] (0x178). Its body really is empty — but
/// the reference is **not** silent about it: the handler raises `ERR_INVALID_PETNAME` on the red
/// line. Decision 1066's claim that it does nothing was a mis-attributed handler; see that
/// constant's own note.
pub const CMSG_PET_RENAME: u16 = 0x0177; // 375
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
@@ -643,6 +643,27 @@ pub enum ServerPacket {
spell_id: u32,
outcome: CastOutcome,
},
/// `SMSG_PET_TAME_FAILURE` — one `PetTameFailureReason` byte; the red line's text comes from
/// [`super::pet::pet_tame_failure_key`].
PetTameFailure {
reason: u8,
},
/// `SMSG_PET_NAME_INVALID` — the refused rename. Empty body: the opcode IS the message.
PetNameInvalid,
/// `SMSG_PET_BROKEN` — the pet's loyalty hit zero and it ran away. Empty body.
PetBroken,
/// `SMSG_PET_ACTION_SOUND` — the pet's voice: which unit, and which of the two talk
/// selectors ([`super::pet::PET_TALK_ORDER`] / [`super::pet::PET_TALK_ATTACK`]).
PetActionSound {
pet_guid: u64,
talk: u32,
},
/// `SMSG_PET_DISMISS_SOUND` — a `CreatureModelData` id and the point to play its column-29
/// kit at. No guid: the pet is already gone.
PetDismissSound {
model_id: u32,
position: Vector3d,
},
/// `SMSG_ATTACKSTART` — a unit began melee auto-attack (including our own echo).
AttackStart {
attacker: u64,
@@ -1661,6 +1682,11 @@ impl ServerPacket {
ServerPacket::PetSpells(_) => "SMSG_PET_SPELLS".into(),
ServerPacket::PetMode(_) => "SMSG_PET_MODE".into(),
ServerPacket::PetActionFeedback { .. } => "SMSG_PET_ACTION_FEEDBACK".into(),
ServerPacket::PetTameFailure { .. } => "SMSG_PET_TAME_FAILURE".into(),
ServerPacket::PetNameInvalid => "SMSG_PET_NAME_INVALID".into(),
ServerPacket::PetBroken => "SMSG_PET_BROKEN".into(),
ServerPacket::PetActionSound { .. } => "SMSG_PET_ACTION_SOUND".into(),
ServerPacket::PetDismissSound { .. } => "SMSG_PET_DISMISS_SOUND".into(),
ServerPacket::PetCastFailed { .. } => "SMSG_PET_CAST_FAILED".into(),
ServerPacket::AttackStart { .. } => "SMSG_ATTACKSTART".into(),
ServerPacket::AttackStop { .. } => "SMSG_ATTACKSTOP".into(),
@@ -612,6 +612,21 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
let (spell_id, outcome) = pet::read_pet_cast_failed(&mut r)?;
ServerPacket::PetCastFailed { spell_id, outcome }
}
opcode::SMSG_PET_TAME_FAILURE => ServerPacket::PetTameFailure {
reason: pet::read_pet_tame_failure(&mut r)?,
},
// Both bodies really are empty — the opcode is the whole message on either (vmangos's
// `AppendBodyTo` writes nothing, and the reference's handlers read nothing).
opcode::SMSG_PET_NAME_INVALID => ServerPacket::PetNameInvalid,
opcode::SMSG_PET_BROKEN => ServerPacket::PetBroken,
opcode::SMSG_PET_ACTION_SOUND => {
let (pet_guid, talk) = pet::read_pet_action_sound(&mut r)?;
ServerPacket::PetActionSound { pet_guid, talk }
}
opcode::SMSG_PET_DISMISS_SOUND => {
let (model_id, position) = pet::read_pet_dismiss_sound(&mut r)?;
ServerPacket::PetDismissSound { model_id, position }
}
opcode::SMSG_ATTACKSTART => {
let (attacker, victim) = attack::read_attack_start(&mut r)?;
ServerPacket::AttackStart { attacker, victim }
+67 -1
View File
@@ -22,7 +22,7 @@
use std::io::{self, Read};
use crate::wire::{read_u16_le, read_u32_le, read_u64_le, read_u8};
use crate::wire::{read_u16_le, read_u32_le, read_u64_le, read_u8, Vector3d};
/// Pet action-bar slots (vmangos `MAX_UNIT_ACTION_BAR_INDEX` = `ACTION_BAR_INDEX_END(10) -
/// ACTION_BAR_INDEX_START(0)`, `Objects/UnitDefines.h:781-787`); the client's own
@@ -375,6 +375,72 @@ pub(super) fn read_pet_action_feedback(r: &mut impl Read) -> io::Result<u8> {
read_u8(r)
}
/// Read `SMSG_PET_TAME_FAILURE` (VERIFIED vmangos `PetTameFailure::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:122-125`): one reason byte. [`pet_tame_failure_key`] is what the
/// reference does with it.
pub(super) fn read_pet_tame_failure(r: &mut impl Read) -> io::Result<u8> {
read_u8(r)
}
/// The `SMSG_PET_TAME_FAILURE` reason -> the `GlobalStrings.lua` key whose text fills
/// `ERR_TAME_FAILED`'s lone `%s` — the reference's jump table at `0x6e6ac0`, read out of the
/// image dword for dword.
///
/// `0x6e6a20` computes `reason - 1` and bounds it at `0xa`, so **`1..=11` index the table and
/// everything else takes the default arm** `0x6e6a87` -> `PETTAME_UNKNOWNERROR`. That default is
/// reachable in play: vmangos's own enum has a twelfth value (`PETTAME_UNKNOWNERROR = 12`) which
/// falls out of range and lands on it, arriving at the same string by the other road.
///
/// The eleven pairs are byte-identical to vmangos's `PetTameFailureReason`
/// (`src/game/SharedDefines.h:1707`) — the two ends agree without either having been fitted to
/// the other.
pub fn pet_tame_failure_key(reason: u8) -> &'static str {
match reason {
1 => "PETTAME_INVALIDCREATURE",
2 => "PETTAME_TOOMANY",
3 => "PETTAME_CREATUREALREADYOWNED",
4 => "PETTAME_NOTTAMEABLE",
5 => "PETTAME_ANOTHERSUMMONACTIVE",
6 => "PETTAME_UNITSCANTTAME",
7 => "PETTAME_NOPETAVAILABLE",
8 => "PETTAME_INTERNALERROR",
9 => "PETTAME_TOOHIGHLEVEL",
10 => "PETTAME_DEAD",
11 => "PETTAME_NOTDEAD",
_ => "PETTAME_UNKNOWNERROR",
}
}
/// `SMSG_PET_ACTION_SOUND`'s selector: the pet acknowledging an **order** (vmangos
/// `PET_TALK_SPECIAL_SPELL`, sent when a pet bar spell press is accepted —
/// `PetHandler.cpp:523`, `PetAI.cpp:336`).
pub const PET_TALK_ORDER: u32 = 0;
/// `SMSG_PET_ACTION_SOUND`'s selector: the pet **attacking** (vmangos `PET_TALK_ATTACK`, sent
/// from `Unit.cpp:8941` when an attack order is taken).
pub const PET_TALK_ATTACK: u32 = 1;
/// Read `SMSG_PET_ACTION_SOUND` (VERIFIED vmangos `PetActionSound::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:96-100`, and the reference's own reader at `0x6040ca`/`0x6040db`):
/// `u64 petGuid` then the `u32` talk selector.
///
/// The selector is NOT a `SoundEntries` id — it is [`PET_TALK_ORDER`] / [`PET_TALK_ATTACK`], and
/// the sound comes off the pet's own `CreatureSoundData` row. Worth saying because the opcode's
/// name invites the other reading, and a two-value selector and a kit id are indistinguishable
/// on the wire.
pub(super) fn read_pet_action_sound(r: &mut impl Read) -> io::Result<(u64, u32)> {
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// Read `SMSG_PET_DISMISS_SOUND` — `u32 creatureModelDataId` then `f32` x/y/z.
///
/// VERIFIED off **the reference's own reader**, which is the only authority there is: `0x604140`
/// takes one `0x418e30` (u32) then three `0x419130` (f32) in that order, and vmangos has no
/// sender to cross-check against. The `+1.0` the handler adds to `z` afterwards is a *play*
/// detail, not a wire one, so it is not applied here.
pub(super) fn read_pet_dismiss_sound(r: &mut impl Read) -> io::Result<(u32, Vector3d)> {
Ok((read_u32_le(r)?, Vector3d::read(r)?))
}
/// Read `SMSG_PET_CAST_FAILED` (VERIFIED vmangos `PetCastFailed::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:127-132`): `u32 spellId, u8 status, u8 reason`. The pet's twin of
/// `SMSG_CAST_RESULT` — the same `SpellCastResult` vocabulary, decoded the same way (status `2` =
@@ -229,20 +229,22 @@ fn set_faction_standing_parses_and_decodes() {
/// opcode numbers.
#[test]
fn unknown_opcode_decodes_to_packet_dropped() {
// 0x0324 = SMSG_PET_ACTION_SOUND — assigned in 1.12.1 but deliberately unparsed by benilla.
// 0x021E = SMSG_SET_REST_START — assigned in 1.12.1 but deliberately unparsed by benilla.
// (The sentinel used to be 0x0319 `MSG_MOVE_TIME_SKIPPED`, which decision 1935 gave a parse
// arm: an observed mover's skipped time has to reach its relay chain or that unit hitches.)
let packet = messages::parse_server(0x0324, &hx("0102030405")).unwrap();
assert!(matches!(packet, ServerPacket::Other { opcode: 0x0324 }));
// arm; then 0x0324 `SMSG_PET_ACTION_SOUND`, which decision 2039 gave one — the pet's voice
// turned out to drive two real `CreatureSoundData` columns. A sentinel earning a parse arm is
// this test working, not this test breaking.)
let packet = messages::parse_server(0x021E, &hx("0102030405")).unwrap();
assert!(matches!(packet, ServerPacket::Other { opcode: 0x021E }));
match decode(packet).as_slice() {
[SessionEvent::PacketDropped {
opcode: 0x0324,
opcode: 0x021E,
unparseable: false,
}] => {}
other => panic!("expected one PacketDropped event, got {other:?}"),
}
// The generated name table: a known opcode resolves, an unassigned number doesn't.
assert_eq!(messages::opcode_name(0x0324), Some("SMSG_PET_ACTION_SOUND"));
assert_eq!(messages::opcode_name(0x021E), Some("SMSG_SET_REST_START"));
assert_eq!(
messages::opcode_name(messages::opcode::SMSG_UPDATE_OBJECT),
Some("SMSG_UPDATE_OBJECT")
@@ -530,3 +532,94 @@ fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
}
));
}
/// The hunter pet-feedback family (decision 2039) — the three arms that were name-table-only, on
/// their real bodies.
///
/// The two **empty** bodies are the point of half this test: `SMSG_PET_NAME_INVALID` and
/// `SMSG_PET_BROKEN` carry nothing at all (vmangos's `AppendBodyTo` writes nothing; the
/// reference's handlers read nothing), so "parses" here means "the opcode alone produced the
/// event" — and a body that arrives with junk on it must not change that.
#[test]
fn pet_feedback_family_parses_and_decodes() {
use benilla_protocol::messages::opcode;
// SMSG_PET_TAME_FAILURE: one reason byte. 9 = PETTAME_TOOHIGHLEVEL.
let p = messages::parse_server(opcode::SMSG_PET_TAME_FAILURE, &hx("09")).unwrap();
assert!(matches!(p, ServerPacket::PetTameFailure { reason: 9 }));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetTameFailure { reason: 9 }]
));
// The reason -> GlobalStrings key table, at both ends of the reference's bounds check
// (`0x6e6a20`: `reason - 1` bounded at `0xa`) and in the middle.
assert_eq!(messages::pet_tame_failure_key(1), "PETTAME_INVALIDCREATURE");
assert_eq!(
messages::pet_tame_failure_key(5),
"PETTAME_ANOTHERSUMMONACTIVE"
);
assert_eq!(messages::pet_tame_failure_key(11), "PETTAME_NOTDEAD");
// Out of range on BOTH sides falls to the default arm — including vmangos's own 12th value,
// which is `PETTAME_UNKNOWNERROR` in the enum and reaches the same string by the other road.
assert_eq!(messages::pet_tame_failure_key(0), "PETTAME_UNKNOWNERROR");
assert_eq!(messages::pet_tame_failure_key(12), "PETTAME_UNKNOWNERROR");
assert_eq!(messages::pet_tame_failure_key(200), "PETTAME_UNKNOWNERROR");
// SMSG_PET_NAME_INVALID / SMSG_PET_BROKEN: empty bodies, and unread ones — a body with bytes
// on it still decodes to the bare event, because neither handler reads one.
for body in ["", "deadbeef"] {
let p = messages::parse_server(opcode::SMSG_PET_NAME_INVALID, &hx(body)).unwrap();
assert!(matches!(p, ServerPacket::PetNameInvalid), "body {body:?}");
assert!(matches!(decode(p)[..], [SessionEvent::PetNameInvalid]));
let p = messages::parse_server(opcode::SMSG_PET_BROKEN, &hx(body)).unwrap();
assert!(matches!(p, ServerPacket::PetBroken), "body {body:?}");
assert!(matches!(decode(p)[..], [SessionEvent::PetBroken]));
}
// SMSG_PET_ACTION_SOUND: u64 guid then the u32 talk selector.
let body = hx("2a0000000000401001000000");
let p = messages::parse_server(opcode::SMSG_PET_ACTION_SOUND, &body).unwrap();
assert!(matches!(
p,
ServerPacket::PetActionSound {
pet_guid: 0x1040_0000_0000_002A,
talk: messages::PET_TALK_ATTACK,
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetActionSound {
pet_guid: 0x1040_0000_0000_002A,
talk: 1
}]
));
// SMSG_PET_DISMISS_SOUND: a CreatureModelData id then a raw WoW x/y/z. Model 726, at
// (-8949.95, -132.493, 83.5312) — the human starting point, chosen because its bytes are
// recognisable if the field order ever slips. The handler's `+1.0` on z is a PLAY detail and
// must NOT appear here.
let mut body = hx("d6020000"); // model 726
for f in [-8949.95_f32, -132.493, 83.5312] {
body.extend_from_slice(&f.to_le_bytes());
}
let p = messages::parse_server(opcode::SMSG_PET_DISMISS_SOUND, &body).unwrap();
assert!(matches!(
p,
ServerPacket::PetDismissSound {
model_id: 726,
position: benilla_protocol::wire::Vector3d {
x: -8949.95,
y: -132.493,
z: 83.5312,
},
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::PetDismissSound {
model_id: 726,
position: [-8949.95, -132.493, 83.5312],
}]
));
}
+20 -4
View File
@@ -240,13 +240,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// → whether the given unit is riding a taxi. Only our own player is tracked (the reference
// UI only ever asks about "player"); any other token reads false.
// `UnitOnTaxi(unit)` (`0x517a40`) → **the number 1 or nil**, never a Lua boolean — the unit
// predicate family's one return shape (decisions 1830, 2043; the push law is
// `crate::script::binding_abi::predicate`). It lives here rather than beside its 22 siblings
// because the ride flag it reads is this module's, not `UnitState`'s — which is exactly how it
// came to be the one predicate still answering a `bool` after the family was fixed.
//
// Only our own player is tracked (the reference UI only ever asks about `"player"`); any other
// token reads nil.
//
// The token argument is `Value`, not `String`, so the miss is the reference's own message: the
// binding carries an `lua_isstring` gate at `0x517a48` whose failure arm is
// `luaL_error("Usage: UnitOnTaxi(\"unit\")")` (wow-re
// `ui/scratch/nil-unit-token-arg-law.md` §10, row 33). A Rust `String` parameter raised mlua's
// own type-conversion error there instead.
g.set(
"UnitOnTaxi",
lua.create_function(|lua, unit: String| {
lua.create_function(|lua, unit: mlua::Value| {
let unit =
crate::script::binding_abi::string_arg(lua, unit, r#"Usage: UnitOnTaxi("unit")"#)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(unit.eq_ignore_ascii_case("player") && model.taxi_riding)
Ok(crate::script::binding_abi::predicate(
unit.eq_ignore_ascii_case("player") && model.taxi_riding,
))
})?,
)?;
+2 -2
View File
@@ -50,7 +50,7 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
assert(GetNumRoutes(1) == 0 and GetNumRoutes(2) == 1)
assert(math.abs(TaxiGetSrcX(2, 1) - 0.5) < 1e-6)
assert(math.abs(TaxiGetDestY(2, 1) - 0.8) < 1e-6)
assert(UnitOnTaxi("player") == false)
assert(UnitOnTaxi("player") == nil) -- 1/nil, never a boolean (2043)
TakeTaxiNode(2)
CloseTaxiMap()
@@ -74,6 +74,6 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
// The ride flag reaches UnitOnTaxi.
s.set_on_taxi(true);
s.run(r#"assert(UnitOnTaxi("player") == true)"#).unwrap();
s.run(r#"assert(UnitOnTaxi("player") == 1)"#).unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -56,6 +56,133 @@ fn creature_line_law() {
assert!(s.take_errors().is_empty());
}
/// The snapshot the app pushes for a creature the client has only just seen: the DESCRIPTOR's
/// fields are in (level, reaction, the skinnable flag) and everything the creature record carries
/// — name, subtitle, type word, rank, civilian/leader — is still absent, because they all arrive
/// together in the one `SMSG_CREATURE_QUERY_RESPONSE` that fills `CGUnit+0xb30`.
fn unqueried_wolf() -> UnitState {
UnitState {
exists: true,
has_object: true,
name: None,
health: 30,
max_health: 50,
level: 10,
reaction: 2, // hostile
skinnable: true,
guid: 0xF130_0000_0000_0001,
..Default::default()
}
}
/// **A name still in flight titles the plate `UNKNOWNOBJECT` — never an empty line** (decision
/// 2040, closing 2002's residue).
///
/// The builder's name read is `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), the same resolver
/// `UnitName 0x517020` delegates to, and every one of its misses — a null `CGUnit+0xb30`
/// (`0x609353 je 0x609324`) among them — ends at the same `FrameScript_GetText("UNKNOWNOBJECT")`
/// tail. So the verb and the plate answer the same string for the same instant, and because the
/// string is read out of the VM's `_G` it translates with `GlobalStrings.lua`.
///
/// The builder has NO counterpart to `UnitName`'s two nils: the `"player"` fast path is the
/// *binding's* (`0x517083`, before any resolve), so a player whose name has not arrived titles
/// `UNKNOWNOBJECT` too; and a token resolving to nothing never reaches a builder at all — the
/// entry gate hands back no object, `SetUnit` answers nil and no plate is drawn.
///
/// Failure looks like the empty first line the residue named: `TextLeft1` reads `""`, the plate
/// measures to a blank row, and the level line reads as the tooltip's title.
#[test]
fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_unit("target", Some(unqueried_wolf()));
s.set_player_req_state(PlayerReqState {
level: 12,
..Default::default()
});
s.run(
r#"
UNKNOWNOBJECT = "Unknown" -- GlobalStrings.lua:4444, enUS
local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(tt:SetUnit("target") == 1, "a resolved unit shows, name or no name")
assert(TTTextLeft1:GetText() == UNKNOWNOBJECT,
"the title is the GlobalString, got '" .. tostring(TTTextLeft1:GetText()) .. "'")
-- The record's other lines are absent with it: no subtitle, and the level line takes the
-- bare TOOLTIP_UNIT_LEVEL template because both the CLASS and TYPE slots are the record's.
assert(TTTextLeft2:GetText() == "Level 10", "got " .. TTTextLeft2:GetText())
-- The descriptor's own lines are NOT the record's and show anyway.
assert(TTTextLeft3:GetText() == "Skinnable")
"#,
)
.unwrap();
// The query answers: the very next render replaces the placeholder with the real name and
// fills the record's lines. (Live, the hover feed re-drives this without the mouse moving —
// its rebuild key is the whole line-affecting snapshot, `ui_tooltip::lines_view`.)
let mut answered = unqueried_wolf();
answered.name = Some("Timber Wolf".into());
answered.subtitle = Some("Alpha".into());
answered.creature_type_name = Some("Beast".into());
answered.rank = 2;
s.set_unit("target", Some(answered));
s.run(
r#"
assert(TT:SetUnit("target") == 1)
assert(TTTextLeft1:GetText() == "Timber Wolf", "the answer replaced UNKNOWNOBJECT")
assert(TTTextLeft2:GetText() == "Alpha")
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
assert(TTTextLeft4:GetText() == "Skinnable")
"#,
)
.unwrap();
// An EMPTY (or absent) global is the same miss as no global — `0x609324`'s own check, which
// falls to the binary's literal `0x860fa4`.
s.set_unit("target", Some(unqueried_wolf()));
s.run(
r#"
UNKNOWNOBJECT = ""
assert(TT:SetUnit("target") == 1)
assert(TTTextLeft1:GetText() == "Unknown Being", "got " .. TTTextLeft1:GetText())
"#,
)
.unwrap();
// The `"player"` fast path is the BINDING's, not the builder's: a player whose name-cache row
// has not answered titles the same placeholder, where `UnitName("player")` would push nil.
s.set_unit(
"player",
Some(UnitState {
exists: true,
has_object: true,
name: None,
level: 12,
is_player: true,
player_controlled: true,
guid: 0x0000_0000_0000_0007,
..Default::default()
}),
);
s.run(
r#"
UNKNOWNOBJECT = "Unknown"
assert(UnitName("player") == nil, "the binding's own fast path still pushes nil (2002)")
assert(TT:SetUnit("player") == 1)
assert(TTTextLeft1:GetText() == UNKNOWNOBJECT,
"the builder has no player fast path, got " .. tostring(TTTextLeft1:GetText()))
"#,
)
.unwrap();
// The GUID-0 counterpart: a recognised token naming nothing resolves to no object, so no
// builder runs — nil, and no plate. (There is no third answer; the builder never sees it.)
s.run(r#"assert(TT:SetUnit("party4") == nil, "an absent unit draws no plate at all")"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The faction-name line sits between the level line and "PvP" (the builder-tail block the §2
/// order omitted — the director's Marshal McBride reference: Level, Stormwind, PvP); the
/// CIVILIAN line is the dishonorable-kill warning, whole gate (`0x612550`): PvP bit + civilian
+21 -9
View File
@@ -3,7 +3,9 @@
//!
//! - NAME (gold — FrameXML recolors `TextLeft1` by reaction on `UPDATE_MOUSEOVER_UNIT`, exactly
//! like the reference's `GameTooltip_UnitColor`; the guild line is likewise FrameXML's and
//! joins when guild data streams);
//! joins when guild data streams). A name still in flight reads `UNKNOWNOBJECT`, never an
//! empty line — the builder's name read is `0x609210`, the same resolver `UnitName` uses, and
//! its miss tail is the same string (decisions 2002/2040);
//! - the creature SUBTITLE ("Stable Master") — white;
//! - the LEVEL line, composed from three slots over the four `TOOLTIP_UNIT_LEVEL*` templates:
//! level text (`"??"` for a world boss, a much-higher hostile, or level ≤ 0 — the hostile
@@ -31,7 +33,7 @@ use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empt
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
use super::unit::{is_civilian_kill, level_reads_unknown};
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
use super::{KindState, Model, UnitState};
use crate::layout::{Anchor, Point};
use crate::widget::FrameHandle;
@@ -138,13 +140,23 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
show_or_hide_empty(lua, h);
return Ok(false);
};
append_line(
lua,
this,
(u.name.clone().unwrap_or_default(), GOLD),
None,
false,
)?;
// The NAME line. A unit whose name query has not answered yet does NOT title an empty plate:
// the builder resolves the name through `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), and
// every one of that function's misses — a creature whose `creaturecache.wdb` record
// (`CGUnit+0xb30`) is still null, a pet whose `petnamecache.wdb` row is absent or stale, a
// player row `namecache.wdb` has not answered — falls to the SAME
// `FrameScript_GetText("UNKNOWNOBJECT")` tail `UnitName` falls to. One seam, one resolver
// ([`unknownobject`], decisions 2002/2040), so the verb and the plate can never disagree.
//
// The builder has no counterpart to `UnitName`'s two nils: the `"player"` fast path is the
// *binding's* (`0x517083`, before any resolve), and a token resolving to GUID 0 never reaches
// a builder at all — the entry gate `0x468460(typemask 8)` hands back no object, `SetUnit`
// answers nil and no plate is drawn (the `unit` early-return above).
let title = match &u.name {
Some(n) => n.clone(),
None => unknownobject(lua)?.to_str()?.to_string(),
};
append_line(lua, this, (title, GOLD), None, false)?;
if let Some(sub) = &u.subtitle {
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
}
+59 -132
View File
@@ -1,13 +1,20 @@
//! The `Unit*` global registrations (see the parent module's doc for the seam and the return
//! shapes): every binding reads the per-token [`UnitState`](super::UnitState) snapshot store
//! through the parent's `with_unit`/`pick_unit_token` helpers.
//!
//! **Every predicate here returns through one function** — [`super::unit_predicate`] when it reads
//! a snapshot field, [`predicate`] when it computes its own bool. Neither ever hands mlua a Rust
//! `bool`: 1.12 pushes the number `1` or `nil` and has no boolean on its binding surface at all
//! (decisions 1830, 2043). A new predicate that open-codes `Value::Integer(1)`/`Value::Nil`, or
//! returns a `bool`, is the drift those two records exist to stop.
use mlua::{Lua, Value};
use super::super::binding_abi::predicate;
use super::super::Model;
use super::{
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
with_unit, SelectionRequest,
unit_predicate, unknownobject, with_unit, SelectionRequest,
};
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
@@ -19,19 +26,6 @@ use super::{
const CLASS_ROGUE: u32 = 4;
const CLASS_DRUID: u32 = 11;
/// `FrameScript_GetText("UNKNOWNOBJECT")` as the name resolvers call it (`0x517220`, `0x609324`):
/// the VM's own GlobalString — read out of `_G` exactly as `0x703bf0` reads it, so a translated
/// `GlobalStrings.lua` translates this too (enUS: `"Unknown"`, `GlobalStrings.lua:4444`) — and,
/// when the global is missing or empty, the binary's own literal `"Unknown Being"` (`0x860fa4`,
/// the bytes at that address in `WoW.exe`). Always a string: this is the "name not yet known"
/// state, and it is never nil. Decision 2002.
fn unknownobject(lua: &Lua) -> mlua::Result<Value> {
match lua.globals().get::<Value>("UNKNOWNOBJECT") {
Ok(Value::String(s)) if !s.as_bytes().is_empty() => Ok(Value::String(s)),
_ => Ok(Value::String(lua.create_string("Unknown Being")?)),
}
}
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
/// surface).
@@ -41,7 +35,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitExists",
lua.create_function(|lua, token: Option<String>| {
with_unit(lua, &token, false, |u| u.exists)
unit_predicate(lua, &token, |u| u.exists)
})?,
)?;
@@ -62,11 +56,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsVisible",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.has_object)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.has_object)
})?,
)?;
@@ -115,7 +105,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?;
// One value, and it is the NUMBER 1 or nil — never a boolean, the same shape the
// rest of this family answers in.
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
}
@@ -157,11 +147,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.as_ref()
.and_then(|t| model.unit(t))
.map_or(0, |u| u.guid);
Ok(if by_flag || guid == model.party.leader_guid {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(by_flag || guid == model.party.leader_guid))
})?,
)?;
@@ -213,7 +199,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
{
Value::Nil
}
Some(None) => unknownobject(lua)?,
Some(None) => Value::String(unknownobject(lua)?),
};
Ok((name, Value::Nil))
})?,
@@ -282,11 +268,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsCorpse",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.corpse_object)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.corpse_object)
})?,
)?;
@@ -311,11 +293,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
Ok(if with_unit(lua, &token, false, |u| u.can_attack)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.can_attack)
})?,
)?;
@@ -339,7 +317,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsDead("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead)
unit_predicate(lua, &token, |u| u.dead)
})?,
)?;
@@ -353,7 +331,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.ghost)
unit_predicate(lua, &token, |u| u.ghost)
})?,
)?;
g.set(
@@ -364,7 +342,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsDeadOrGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead || u.ghost)
unit_predicate(lua, &token, |u| u.dead || u.ghost)
})?,
)?;
@@ -422,11 +400,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if (1..=2).contains(&r) {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate((1..=2).contains(&r)))
})?,
)?;
g.set(
@@ -446,11 +420,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r >= 5 {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(r >= 5))
})?,
)?;
@@ -480,10 +450,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let player_level = model.player_req.level;
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if super::is_civilian_kill(u, player_level) => Value::Integer(1),
_ => Value::Nil,
})
Ok(predicate(
unit.and_then(|u| model.unit(&u))
.is_some_and(|u| super::is_civilian_kill(u, player_level)),
))
})?,
)?;
// UnitPlayerControlled(unit) → 1 if a PLAYER is driving this unit, else nil. `UNIT_FIELD_FLAGS`
@@ -495,20 +465,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"UnitPlayerControlled",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if u.player_controlled => Value::Integer(1),
_ => Value::Nil,
})
Ok(predicate(
unit.and_then(|u| model.unit(&u))
.is_some_and(|u| u.player_controlled),
))
})?,
)?;
g.set(
"UnitIsPlayer",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_player)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_player)
})?,
)?;
@@ -542,11 +508,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
if !ua.exists || !ub.exists {
return Ok(Value::Nil);
}
if a == b || (ua.guid != 0 && ua.guid == ub.guid) {
Ok(Value::Integer(1))
} else {
Ok(Value::Nil)
}
Ok(predicate(a == b || (ua.guid != 0 && ua.guid == ub.guid)))
})?,
)?;
@@ -570,7 +532,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"Usage: UnitAffectingCombat(\"unit\")",
)?;
let hot = with_unit(lua, &Some(token), false, |u| u.exists && u.in_combat)?;
Ok(if hot { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hot))
})?,
)?;
@@ -599,11 +561,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"HasFullControl",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.player_control {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.player_control))
})?,
)?;
@@ -642,7 +600,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
!model.party.members.is_empty()
};
let hit = grouped && (in_group(u.guid) || in_group(u.owner));
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
}
@@ -663,7 +621,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.iter()
.any(|m| m.guid != 0 && m.guid == u.guid)
});
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
@@ -693,7 +651,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
.members
.iter()
.any(|m| m.guid != 0 && m.guid == u.guid)));
Ok(if hit { Value::Integer(1) } else { Value::Nil })
Ok(predicate(hit))
})?,
)?;
@@ -720,7 +678,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let ok = with_unit(lua, &token, false, |u| {
u.exists && u.is_player && u.reaction >= 5
})?;
Ok(if ok { Value::Integer(1) } else { Value::Nil })
Ok(predicate(ok))
})?,
)?;
@@ -746,8 +704,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// The party-frame status predicates (decision 0434 §2/§3): connection, AFK/DND, and the two PvP
// flags. All 1/nil, the live API's own shape for these — unlike UnitIsDead/UnitIsGhost above (a
// stated v1 shortcut, module doc), these are new and follow the era shape from the start.
// flags. All 1/nil, through the family's one push site (2043).
//
// **`UnitIsAFK`/`UnitIsDND` are OURS, not the reference's** — this comment used to claim they
// "follow the era shape from the start", which named the wrong authority. wow-re's raw byte
// census over the whole image finds ZERO occurrences of `UnitIsAFK`, `UnitIsDND`, `IsAFK` or
// `IsDND`, as bindings or as strings (positive control: `UnitIsPVP\0` and `UnitIsGhost\0`
// each return exactly 1) — `ui/scratch/nil-unit-token-arg-law.md` §11. 1.12 surfaces a
// member's AFK/DND state through `GetGuildRosterInfo` and the `CHAT_FLAG_AFK`/`CHAT_FLAG_DND`
// GlobalStrings; there is no unit predicate for it. They wear the family's shape because that
// is the right shape for an invention of ours to wear, not because a binding was read.
g.set(
"UnitIsConnected",
lua.create_function(|lua, token: Value| {
@@ -756,31 +722,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsConnected("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_connected)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_connected)
})?,
)?;
g.set(
"UnitIsAFK",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_afk)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_afk)
})?,
)?;
g.set(
"UnitIsDND",
lua.create_function(|lua, token: Option<String>| {
Ok(if with_unit(lua, &token, false, |u| u.is_dnd)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_dnd)
})?,
)?;
// UnitIsPVP reads the same `pvp` field the unit tooltip's "PvP" line already does (one flag,
@@ -793,11 +747,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsPVP("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.pvp)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.pvp)
})?,
)?;
g.set(
@@ -808,11 +758,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitIsPVPFreeForAll("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_pvp_ffa)? {
Value::Integer(1)
} else {
Value::Nil
})
unit_predicate(lua, &token, |u| u.is_pvp_ffa)
})?,
)?;
// UnitFactionGroup(unit) → (englishGroup, localizedName), the pair the PvP icon law reads:
@@ -932,7 +878,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
r#"Usage: UnitHasRelicSlot("unit")"#,
)?);
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
Ok(if has { Value::Integer(1) } else { Value::Nil })
Ok(predicate(has))
})?,
)?;
@@ -1137,13 +1083,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsCharmed",
lua.create_function(|lua, token: Option<String>| {
with_unit(lua, &token, Value::Nil, |u| {
if u.charmed {
Value::Number(1.0)
} else {
Value::Nil
}
})
unit_predicate(lua, &token, |u| u.charmed)
})?,
)?;
@@ -1251,11 +1191,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"IsResting",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.resting {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.resting))
})?,
)?;
// PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read
@@ -1268,22 +1204,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"PartialPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.partial_play_time {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.partial_play_time))
})?,
)?;
g.set(
"NoPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.no_play_time {
Value::Integer(1)
} else {
Value::Nil
})
Ok(predicate(model.no_play_time))
})?,
)?;
// GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always
@@ -1505,12 +1433,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"ClearTarget",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.unit("target").is_some_and(|u| u.exists) {
let had = model.unit("target").is_some_and(|u| u.exists);
if had {
model.target_clear = true;
Ok(Value::Integer(1))
} else {
Ok(Value::Nil)
}
Ok(predicate(had))
})?,
)?;
+61 -4
View File
@@ -7,16 +7,20 @@
//! `UnitHealth("player")` therefore resolves against a [`UnitState`] the Bevy side deposited that
//! frame — the same shape a real addon sees, with none of the coupling.
//!
//! Return shapes follow the live API (warcraft.wiki.gg): `UnitName` returns the name *or nil*;
//! Return shapes follow the reference: `UnitName` returns the name *or nil*;
//! `UnitHealth`/`UnitHealthMax`/`UnitLevel` return numbers (`UnitLevel` returns **−1** for a
//! unit whose level "can't be told" — [`level_reads_unknown`], the target frame's skull gate);
//! a unit that doesn't exist reports `UnitExists` false and the numbers `0` (nil for the name),
//! a unit that doesn't exist reports `UnitExists` nil and the numbers `0` (nil for the name),
//! exactly as the client does for an absent unit token. Tokens (`"player"`, `"target"`, …) are opaque strings the host never
//! interprets — the app decides what each maps to.
//!
//! **Every predicate in this family answers the number `1` or `nil`, never a Lua boolean**, and it
//! answers it through the one helper — [`unit_predicate`], over
//! [`super::binding_abi::predicate`]. Decision 2043; the law is 1830's, which settled the same
//! question for the widget predicates a family earlier.
//!
//! v1 gaps, stated not hidden: the snapshot carries only the *active* power slot (see the
//! `UnitMana` note below); `UnitIsDead` returns a Lua boolean rather than the client's `1`/nil
//! (truthy either way — the shape a caller branches on is identical); `UnitIsConnected`'s backing
//! `UnitMana` note below); `UnitIsConnected`'s backing
//! field defaults `false`, and no app-side feed sets it `true` for live units yet (decision 0434
//! §2's party-frame predicates land the binding first, the feed follows — see the field doc).
@@ -338,6 +342,34 @@ pub struct UnitState {
pub in_combat: bool,
}
/// `FrameScript_GetText("UNKNOWNOBJECT")` as the client's name resolvers call it — the VM's own
/// GlobalString, read out of `_G` exactly as `0x703bf0` reads it (so a translated
/// `GlobalStrings.lua` translates this too; enUS `"Unknown"`, `GlobalStrings.lua:4444`), falling
/// back to the binary's own literal `"Unknown Being"` (`0x860fa4`) when the global is missing or
/// empty. Always a string — this is the "name not yet known" state, never a nil.
///
/// **ONE home, two callers**, the same reason [`is_civilian_kill`] is a function. `UnitName`
/// `0x517020` reaches it at `0x517220`, and `CGUnit_C::GetUnitName 0x609210` — which the UNIT
/// TOOLTIP builder `0x529fe0` calls for its title line (`0x52a187`) — reaches the *same* tail at
/// `0x609324`, from every one of its misses:
///
/// ```text
/// 609353 je 0x609324 ; creature: [CGUnit+0xb30] (creaturecache.wdb) still null
/// 6092ce je 0x609324 ; pet: petnamecache.wdb row absent, or its stamp stale
/// 609265 je 0x609324 ; player: namecache.wdb has not answered
/// 609324 push 0x0 / or edx,-1 / mov ecx,0x850ed0 ("UNKNOWNOBJECT") / call 0x703bf0
/// 60933c mov eax,0x860fa4 ; the global missing or empty -> "Unknown Being"
/// ```
///
/// One resolver, so the verb and the plate can never disagree about what a unit whose name is
/// still in flight is called (decisions 2002, 2040).
pub fn unknownobject(lua: &Lua) -> mlua::Result<mlua::String> {
match lua.globals().get::<mlua::Value>("UNKNOWNOBJECT") {
Ok(mlua::Value::String(s)) if !s.as_bytes().is_empty() => Ok(s),
_ => lua.create_string("Unknown Being"),
}
}
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
/// grey level colour), indexed by `playerLevel / 5` and clamped to 12 past the table. ONE home
/// for its three consumers: [`unit_is_grey`] (tooltip CIVILIAN gate + the plate's skull/grey
@@ -691,6 +723,31 @@ fn with_unit<T>(
})
}
/// A unit **predicate** binding's whole body: resolve the token, map its snapshot to a bool, and
/// push the family's one return shape — the number `1` or `nil`, never a Lua boolean.
///
/// [`with_unit`] answers whatever `f` answers, so a predicate written on it *directly* hands mlua a
/// Rust `bool`, and mlua pushes tag 1. That is the one shape 1.12 cannot produce: `UnitExists`
/// (`0x515fb0`) pushes `lua_pushnumber` (`0x6f3810`, tag 3, the double `1.0`) or `lua_pushnil`
/// (`0x6f37f0`, tag 0) and nothing else, and so does every other predicate in the family. So the
/// whole family goes through [`super::binding_abi::predicate`] — 1830's widget law, which is the
/// *same* law, finally applied to the unit surface (decision 2043). A predicate that hand-computes
/// its own bool calls that helper directly; one that reads a snapshot field calls this. Both end at
/// the one push site, which is what stops the family drifting apart again.
///
/// **Not every unit binding is a predicate.** `UnitReaction` answers the reaction *number*,
/// `UnitLevel` a level (with `-1` for "can't be told"), `UnitPowerType` a power index whose miss is
/// the number `0` and never nil. Those keep their own shapes and must not be routed here.
fn unit_predicate(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&UnitState) -> bool,
) -> mlua::Result<mlua::Value> {
Ok(super::binding_abi::predicate(with_unit(
lua, token, false, f,
)?))
}
/// The `Unit*`/`GetQuestGreenRange` Lua binding registrations — split from this module's
/// state+laws half purely for size (same seam as the other script modules' install fns).
mod bindings;
+390 -15
View File
@@ -342,7 +342,7 @@ fn set_unit_is_read_by_the_bindings() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("player")"#).unwrap(), 1);
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Benilla"
@@ -353,13 +353,19 @@ fn set_unit_is_read_by_the_bindings() {
100
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 12);
assert!(!s.eval::<bool>(r#"return UnitIsDead("player")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsDead("player") == nil"#)
.unwrap());
}
#[test]
fn absent_token_reports_not_existing_with_zero_numbers() {
let s = UiScript::new().unwrap();
assert!(!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
// **nil, not `false`** — the family's false leg (decision 2043). `== nil` is the comparison a
// boolean would invert, so it is the one worth asserting.
assert!(s
.eval::<bool>(r#"return UnitExists("target") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
assert_eq!(
s.eval::<i64>(r#"return UnitHealthMax("target")"#).unwrap(),
@@ -369,7 +375,9 @@ fn absent_token_reports_not_existing_with_zero_numbers() {
assert!(s
.eval::<bool>(r#"return UnitName("target") == nil"#)
.unwrap());
assert!(!s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsDead("target") == nil"#)
.unwrap());
}
#[test]
@@ -387,8 +395,8 @@ fn a_dead_unit_reports_dead_and_zero_health() {
..Default::default()
}),
);
assert!(s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
assert!(s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("target")"#).unwrap(), 1);
assert_eq!(s.eval::<i64>(r#"return UnitIsDead("target")"#).unwrap(), 1);
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
// Name unknown (no name-query yet) → `UNKNOWNOBJECT`, never nil: the unit RESOLVED, and the
// reference pushes nil for a zero GUID only (decision 2002; the shape is pinned below). A bare
@@ -534,9 +542,11 @@ fn unit_xp_reads_the_player_globals_only_for_the_player_token() {
fn set_unit_none_removes_the_token() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitExists("player")"#).unwrap(), 1);
s.set_unit("player", None);
assert!(!s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert!(s
.eval::<bool>(r#"return UnitExists("player") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 0);
}
@@ -945,9 +955,11 @@ fn a_unit_token_resolves_whatever_its_case() {
.unwrap(),
12
);
assert!(s
.eval::<bool>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap());
assert_eq!(
s.eval::<i64>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap(),
1
);
}
for spelling in ["target", "Target", "TARGET"] {
assert_eq!(
@@ -998,7 +1010,8 @@ fn seating_a_token_folds_its_key_too() {
// …and clearing it through the other spelling really clears it, rather than leaving a shadow.
s.set_unit("TARGET", None);
assert!(
!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap(),
s.eval::<bool>(r#"return UnitExists("target") == nil"#)
.unwrap(),
"removal folds too — no shadowed entry survives"
);
}
@@ -1182,9 +1195,9 @@ fn unit_is_visible_is_object_presence_not_existence() {
None,
"no object -> nil, even though the token still exists"
);
assert!(
s.eval::<bool>(r#"return UnitExists("party1") and true or false"#)
.unwrap(),
assert_eq!(
s.eval::<i64>(r#"return UnitExists("party1")"#).unwrap(),
1,
"...and UnitExists must NOT have moved with it — the roster fallback is the difference"
);
@@ -1525,3 +1538,365 @@ fn a_two_token_predicate_raises_on_either_nil_argument() {
assert!(s.run(&format!("{none}()")).is_ok(), "{none} takes no token");
}
}
// ── The family's return SHAPE, pinned per binding ───────────────────────────────────────────────
/// **Every `Unit*` predicate answers the number `1` or `nil` — never a Lua boolean** (decision
/// 2043; the law is 1830's, made for the widget predicates a family earlier).
///
/// The reference cannot produce a boolean here. Its pushes are `lua_pushnumber 0x6f3810` (tag 3,
/// the double `1.0`) and `lua_pushnil 0x6f37f0` (tag 0); `lua_pushboolean 0x6f39f0` has seven call
/// sites image-wide and not one is a registered binding body, so **tag 1 is never written**
/// (wow-re `ui/scratch/binding-shape-arity-law.md`, `ui/scratch/button-enabled-state.md`, restated
/// `ui.md` l.4700 and l.7348; the per-binding carves for `UnitExists 0x515fb0`,
/// `UnitIsVisible 0x516030`, `UnitIsTapped 0x519c90`, `UnitAffectingCombat 0x517e10`,
/// `UnitPlayerControlled 0x516410`, `UnitInRaid 0x516350`, `UnitIsCharmed 0x516cf0` and
/// `UnitHasRelicSlot 0x519e50` each state it outright).
///
/// **Truthiness is exactly why this needs a test rather than a reading.** `if UnitIsDead(u)` and
/// `if not UnitIsDead(u)` are identical under either shape, and mlua's `bool` conversion maps
/// `Integer(1)` to `true` as happily as it maps `Boolean(true)` — so a test that reads the value
/// as a Rust `bool` passes under the bug. Only `type(...)`, `== 1` and `== nil` separate them, and
/// `== nil` is the one that *inverts*: a boolean `false` is not `nil`, so a caller comparing
/// against `nil` concludes the opposite of the truth.
///
/// The table is the whole registered family, so a new predicate added outside the seam fails here
/// rather than shipping its own shape.
#[test]
fn every_unit_predicate_is_one_or_nil_and_never_a_boolean() {
// (binding, the call as an addon writes it, a UnitState that makes it TRUE)
let table: Vec<(&str, &str, UnitState)> = vec![
(
"UnitExists",
r#"UnitExists("target")"#,
UnitState {
exists: true,
..Default::default()
},
),
(
"UnitIsVisible",
r#"UnitIsVisible("target")"#,
UnitState {
exists: true,
has_object: true,
..Default::default()
},
),
(
"UnitIsDead",
r#"UnitIsDead("target")"#,
UnitState {
exists: true,
dead: true,
..Default::default()
},
),
(
"UnitIsGhost",
r#"UnitIsGhost("target")"#,
UnitState {
exists: true,
ghost: true,
..Default::default()
},
),
(
"UnitIsDeadOrGhost",
r#"UnitIsDeadOrGhost("target")"#,
UnitState {
exists: true,
dead: true,
..Default::default()
},
),
(
"UnitIsCorpse",
r#"UnitIsCorpse("target")"#,
UnitState {
exists: true,
corpse_object: true,
..Default::default()
},
),
(
"UnitIsPlayer",
r#"UnitIsPlayer("target")"#,
UnitState {
exists: true,
is_player: true,
..Default::default()
},
),
(
"UnitPlayerControlled",
r#"UnitPlayerControlled("target")"#,
UnitState {
exists: true,
player_controlled: true,
..Default::default()
},
),
(
"UnitIsCharmed",
r#"UnitIsCharmed("target")"#,
UnitState {
exists: true,
charmed: true,
..Default::default()
},
),
(
"UnitIsTapped",
r#"UnitIsTapped("target")"#,
UnitState {
exists: true,
tapped: true,
..Default::default()
},
),
(
"UnitIsTappedByPlayer",
r#"UnitIsTappedByPlayer("target")"#,
UnitState {
exists: true,
tapped_by_player: true,
..Default::default()
},
),
(
"UnitAffectingCombat",
r#"UnitAffectingCombat("target")"#,
UnitState {
exists: true,
in_combat: true,
..Default::default()
},
),
(
"UnitIsConnected",
r#"UnitIsConnected("target")"#,
UnitState {
exists: true,
is_connected: true,
..Default::default()
},
),
(
"UnitIsAFK",
r#"UnitIsAFK("target")"#,
UnitState {
exists: true,
is_afk: true,
..Default::default()
},
),
(
"UnitIsDND",
r#"UnitIsDND("target")"#,
UnitState {
exists: true,
is_dnd: true,
..Default::default()
},
),
(
"UnitIsPVP",
r#"UnitIsPVP("target")"#,
UnitState {
exists: true,
pvp: true,
..Default::default()
},
),
(
"UnitIsPVPFreeForAll",
r#"UnitIsPVPFreeForAll("target")"#,
UnitState {
exists: true,
is_pvp_ffa: true,
..Default::default()
},
),
(
"UnitHasRelicSlot",
r#"UnitHasRelicSlot("target")"#,
UnitState {
exists: true,
has_relic_slot: true,
..Default::default()
},
),
(
"UnitCanAttack",
r#"UnitCanAttack("player", "target")"#,
UnitState {
exists: true,
can_attack: true,
..Default::default()
},
),
(
"UnitIsEnemy",
r#"UnitIsEnemy("player", "target")"#,
UnitState {
exists: true,
reaction: 1,
..Default::default()
},
),
(
"UnitIsFriend",
r#"UnitIsFriend("player", "target")"#,
UnitState {
exists: true,
reaction: 5,
..Default::default()
},
),
(
"UnitCanCooperate",
r#"UnitCanCooperate("player", "target")"#,
UnitState {
exists: true,
is_player: true,
reaction: 5,
..Default::default()
},
),
(
"UnitIsUnit",
r#"UnitIsUnit("target", "target")"#,
UnitState {
exists: true,
guid: 42,
..Default::default()
},
),
];
for (name, call, live) in table {
// ---- the TRUE leg: the number 1, one value, and NOT a boolean ----
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(live));
assert_eq!(
s.eval::<String>(&format!("return type({call})")).unwrap(),
"number",
"{name} pushes a boolean where the reference pushes tag 3 (lua_pushnumber)"
);
assert_eq!(
s.eval::<i64>(&format!("return {call}")).unwrap(),
1,
"{name}'s true leg is the number 1"
);
assert!(
s.eval::<bool>(&format!("return {call} == 1")).unwrap(),
"{name}: the corpus idiom `== 1` must match on the true leg"
);
assert!(
!s.eval::<bool>(&format!("return {call} == true")).unwrap(),
"{name}: `== true` must NEVER match — the reference cannot push tag 1"
);
assert_eq!(
s.eval::<i64>(&format!("return select('#', {call})"))
.unwrap(),
1,
"{name} returns exactly one value on the true leg"
);
// ---- the FALSE leg: nil, one value, and NOT `false` ----
// The token is cleared rather than re-flagged, so this also covers the "no such unit" arm
// the reference shares with a false predicate.
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", None);
assert!(
s.eval::<bool>(&format!("return {call} == nil")).unwrap(),
"{name}'s false leg is nil — a boolean `false` is not nil, and a caller comparing \
against nil would read the opposite of the truth"
);
assert!(
!s.eval::<bool>(&format!("return {call} == false")).unwrap(),
"{name}: `== false` must never match"
);
assert_eq!(
s.eval::<i64>(&format!("return select('#', {call})"))
.unwrap(),
1,
"{name} returns exactly one value on the false leg too — nil, never zero values"
);
}
}
/// The corpus idiom, spelled the way an addon spells it, on a live token and a dead one.
///
/// This is the shape a `bool` breaks and truthiness hides: `if UnitExists("target") == 1` is
/// simply never true under a boolean, and `if UnitExists("target") == nil` is never true either —
/// the branch silently picks the wrong arm in both directions at once.
#[test]
fn the_equals_one_idiom_branches_correctly_on_a_live_and_a_dead_token() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(player()));
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == 1 then return "live" else return "dead" end"#
)
.unwrap(),
"live"
);
s.set_unit("target", None);
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == 1 then return "live" else return "dead" end"#
)
.unwrap(),
"dead"
);
assert_eq!(
s.eval::<String>(
r#"if UnitExists("target") == nil then return "gone" else return "here" end"#
)
.unwrap(),
"gone",
"the `== nil` half — the comparison a boolean INVERTS"
);
}
/// `UnitOnTaxi` (`0x517a40`) is the family's one predicate registered outside `unit/`, over the
/// taxi module's own ride flag — which is how it stayed a `bool` when its 22 siblings were fixed.
/// Same shape, and the same `Usage:` raise its gate at `0x517a48` carries.
#[test]
fn unit_on_taxi_is_one_or_nil_and_raises_the_reference_usage() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s
.eval::<bool>(r#"return UnitOnTaxi("player") == nil"#)
.unwrap());
s.set_on_taxi(true);
assert_eq!(
s.eval::<String>(r#"return type(UnitOnTaxi("player"))"#)
.unwrap(),
"number"
);
assert_eq!(s.eval::<i64>(r#"return UnitOnTaxi("player")"#).unwrap(), 1);
assert!(!s
.eval::<bool>(r#"return UnitOnTaxi("player") == true"#)
.unwrap());
// Any other token is nil, not false — only our own player is tracked.
assert!(s
.eval::<bool>(r#"return UnitOnTaxi("target") == nil"#)
.unwrap());
let err = s.eval::<mlua::Value>("return UnitOnTaxi()").unwrap_err();
assert!(
err.to_string().contains(r#"Usage: UnitOnTaxi("unit")"#),
"the gate's own message, not mlua's type error: {err}"
);
}
+195 -16
View File
@@ -363,12 +363,24 @@ pub fn register_uv(
bevy::log::warn_once!("mat-anim table full — a UV-scroll batch stays at its seed");
return;
};
let Some(mat) = materials.get_mut(id) else {
// **Through the parked half, not `Assets::get_mut`** (decision 2038). A material this
// spawner just built is not in the store yet — `model_render::lazy` reserves its handle and
// parks the value until something view-visible binds it — so the store-only read returned
// `None` for *every* registration, freed the slot again and left the registry empty: no UV
// scroll and no animated tint anywhere in the world, silently, from the day deferral landed.
let Some(seed) = crate::model_render::lazy::with_material_mut(materials, id, |mat| {
let seed = [mat.extension.sun_scale.z, mat.extension.sun_scale.w];
mat.extension.anim_slots.x = f32::from(slot);
seed
}) else {
// Neither half holds it: a dead or foreign handle, and nothing to animate. Loud, because
// the silent version of this branch is what cost three days of frozen water.
bevy::log::warn_once!(
"mat-anim: no material behind {id} — a UV-scroll batch stays at its seed"
);
table.free(slot);
return;
};
let seed = [mat.extension.sun_scale.z, mat.extension.sun_scale.w];
mat.extension.anim_slots.x = f32::from(slot);
if let UvLoop::PerSeq { host, .. } = &anim {
// The breadcrumb for the lane that has no other tell: a per-placement material is invisible
// in every count (it is one more material, one more row), so "did the bubbles take the new
@@ -431,10 +443,22 @@ pub(super) fn tick_anim_materials(
bevy::platform::collections::HashSet<AssetId<benilla_assets::materials::WowModelMaterial>>,
>,
) {
if (uv_reg.0.is_empty() && tint_reg.0.is_empty())
|| crate::dev_state::deterministic_run()
|| matanim_off(&real)
{
if uv_reg.0.is_empty() && tint_reg.0.is_empty() {
// **The tripwire for decision 2038's whole class.** [`AnimMatPart`] and the registration
// next to it share ONE predicate at the spawn site, so a world holding marked parts with
// an empty registry is never "a scene with no animated content" — it is this lane dead,
// which is exactly how every waterfall, canal and lava sheet in the game froze silently
// for three days. Free: the marker is archetype-filtered, so this is one iterator probe
// on a frame that was about to return anyway, and it says nothing in the ordinary empty
// case.
if parts.iter().next().is_some() {
bevy::log::warn_once!(
"mat-anim: parts are marked animated but NOTHING is registered — the UV/tint lane is dead"
);
}
return;
}
if crate::dev_state::deterministic_run() || matanim_off(&real) {
return;
}
drawn.clear();
@@ -459,7 +483,12 @@ pub(super) fn tick_anim_materials(
// and its slot zeroes back to identity ([`crate::mat_anim_table`]'s free law).
let gseq_now = f64::from(now);
uv_reg.0.retain(|id, entry| {
if !materials.contains(*id) {
// **Alive means either half holds it** (decision 2038): a material still parked by
// `model_render::lazy` — built, handle reserved, nothing view-visible bound to it yet —
// is not in the store, and `Assets::contains` alone reads that as death. Evicting there
// would drop the entry a spawn just made and never rebuild it, because registration
// happens once, at spawn.
if !crate::model_render::lazy::holds(&materials, *id) {
table.free(entry.slot);
return false;
}
@@ -472,7 +501,7 @@ pub(super) fn tick_anim_materials(
true
});
tint_reg.0.retain(|id, entry| {
if !materials.contains(*id) {
if !crate::model_render::lazy::holds(&materials, *id) {
table.free(entry.slot);
return false;
}
@@ -589,19 +618,169 @@ pub fn register_tint(
bevy::log::warn_once!("mat-anim table full — a tint batch stays at its seed");
return;
};
let Some(mat) = materials.get_mut(id) else {
// The parked half, exactly as [`register_uv`] reaches it (decision 2038).
let Some(seed) = crate::model_render::lazy::with_material_mut(materials, id, |mat| {
let seed = [
mat.extension.tint.x,
mat.extension.tint.y,
mat.extension.tint.z,
];
mat.extension.anim_slots.y = f32::from(slot);
seed
}) else {
bevy::log::warn_once!("mat-anim: no material behind {id} — a tint batch stays at its seed");
table.free(slot);
return;
};
let seed = [
mat.extension.tint.x,
mat.extension.tint.y,
mat.extension.tint.z,
];
mat.extension.anim_slots.y = f32::from(slot);
reg.0.insert(id, TintAnimEntry { anim, slot, seed });
}
/// **The animated-material probe** (`WOW_MATANIM_PROBE=<secs>`): once, `secs` after boot, print
/// one line per live [`AnimMatPart`] — the parts whose material can be a [`UvAnimMaterials`] /
/// [`TintAnimMaterials`] key — with everything that decides whether it actually moves on screen:
///
/// ```text
/// matanim <model>#<uid> mat <asset id>[ FAR][ PARKED] anim_slots (1, 0, 0) vis Inherited drawn 1
/// uv slot 1 seed (+0.0000,+0.0000) row (+0.0000,-0.2122) live (+0.0000,-0.2122)
/// shared period 9.967s loop (+0.0000,+0.0000) (+0.0000,-0.2542) (+0.0000,-0.5083) (+0.0000,-0.7542)
/// ```
/// (one line per part; wrapped here)
///
/// Four questions, one line each, because a frozen scroll can fail at any of them and the pixels
/// say the same thing every time: is the batch **registered** at all (a missing line is a batch
/// whose loop never reached the registry — an exhausted table, a period-0 constant, a lane that
/// forgot to register); does its **loop** actually move (the four quarter-phase samples — a
/// flat row here is an asset that scrolls nowhere, not a renderer fault); is the **table row**
/// live (the value the shader will read — flat while the loop moves means the tick's draw gate
/// never picked this material up, which is 1375's failure mode); and is the part **drawn** (the
/// gate's own input).
///
/// It samples the loop itself rather than watching the row over time, so it is a **one-shot that
/// works in a deterministic capture** — where [`tick_anim_materials`] is skipped entirely and the
/// rows stay zero by design. In a live run the `row` column is the tick's real output and the two
/// halves can be compared directly.
#[allow(clippy::too_many_arguments)]
pub(super) fn matanim_probe(
time: Res<Time>,
uv_reg: Res<UvAnimMaterials>,
tint_reg: Res<TintAnimMaterials>,
table: Res<crate::mat_anim_table::MatAnimTable>,
materials: Res<Assets<benilla_assets::materials::WowModelMaterial>>,
twins: Res<crate::model_render::FarSideTwins>,
hosts: SeqHosts,
parts: Query<ProbeReadout, With<AnimMatPart>>,
mut fired: Local<bool>,
) {
let Some(at) = probe_at() else { return };
// Not latched while the world is still empty: a capture holds the game clock at 0 while the
// scene builds, so "elapsed >= at" alone would fire the probe on frame one, before a single
// doodad had spawned, and report nothing.
if *fired || time.elapsed_secs() < at || parts.iter().next().is_none() {
return;
}
*fired = true;
let now = time.elapsed_secs();
let gseq_now = f64::from(now);
let mut rows: Vec<String> = Vec::new();
for (mat, vis, vv, obj) in &parts {
// A far-classified part carries the TWIN's id; the registry is always keyed by the near
// one — the same fold `tick_anim_materials`' draw scan makes (1375).
let far = twins.near_of(mat.id());
let id = far.unwrap_or(mat.id());
let label = obj.map_or_else(|| "-".to_string(), |o| format!("{}#{}", o.label, o.id));
// Through the parked half (`lazy::with_material`), or the readout would report every
// not-yet-drawn material's stamp as a zero it never had — the deferral confusion this
// probe exists to resolve (decision 2038). `parked` says which half answered.
let parked = !materials.contains(id);
let slots =
crate::model_render::lazy::with_material(&materials, id, |m| m.extension.anim_slots)
.unwrap_or_default();
let uv = uv_reg.0.get(&id).map(|e| {
let playing = host_seq(&hosts, e.host());
// The per-placement lane has one loop per sequence, so its phase sweep is meaningless
// without a play head — it prints the head instead, and samples on the shared clock.
let (lane, period) = match &e.anim {
UvLoop::Shared(a) => (format!("shared period {:.3}s", a.period), a.period),
UvLoop::PerSeq { .. } => (format!("per-seq playing {playing:?}"), 0.0),
};
// Four quarter-phase samples of the loop itself: a flat set is an asset that scrolls
// nowhere, whatever the row says.
let phases: Vec<String> = (0u8..4)
.map(|i| {
let t = period * f32::from(i) / 4.0;
let d = e.delta(t, f64::from(t), playing);
format!("({:+.4},{:+.4})", d[0], d[1])
})
.collect();
let live = e.delta(now, gseq_now, playing);
format!(
"uv slot {} seed ({:+.4},{:+.4}) row ({:+.4},{:+.4}) live ({:+.4},{:+.4}) {lane} loop {}",
e.slot,
e.seed[0],
e.seed[1],
table.row(e.slot)[0],
table.row(e.slot)[1],
live[0],
live[1],
phases.join(" "),
)
});
let tint = tint_reg.0.get(&id).map(|e| {
format!(
"tint slot {} row ({:+.4},{:+.4},{:+.4})",
e.slot,
table.row(e.slot)[0],
table.row(e.slot)[1],
table.row(e.slot)[2],
)
});
let channels = match (uv, tint) {
(None, None) => "UNREGISTERED".to_string(),
(a, b) => [a, b].into_iter().flatten().collect::<Vec<_>>().join(" · "),
};
rows.push(format!(
"matanim {label} mat {id}{}{} anim_slots ({}, {}, {}) vis {vis:?} drawn {} {channels}",
if far.is_some() { " FAR" } else { "" },
if parked { " PARKED" } else { "" },
slots.x as u32,
slots.y as u32,
slots.z as u32,
u8::from(vv.is_some_and(|v| v.get())),
));
}
rows.sort();
bevy::log::info!(
"matanim probe at {now:.2}s — {} animated part(s), {} uv / {} tint registry entries",
rows.len(),
uv_reg.0.len(),
tint_reg.0.len(),
);
for r in rows {
bevy::log::info!("{r}");
}
}
/// One animated part as [`matanim_probe`] reads it: the material it is bound to (the far twin's
/// id while it is far-classified), the two halves of the draw verdict the tick's gate turns on,
/// and the placement that names it. A tuple alias because the inline form trips the workspace
/// gate's `type_complexity`, the same shape `debug_panel::inspect`'s readouts take.
type ProbeReadout = (
&'static MeshMaterial3d<benilla_assets::materials::WowModelMaterial>,
&'static Visibility,
Option<&'static bevy::camera::visibility::ViewVisibility>,
Option<&'static crate::interact::WorldObject>,
);
/// `WOW_MATANIM_PROBE` — unset is off; bare (or unparseable) fires at 20 s. Read once
/// ([`matanim_off`]'s pattern): the system runs every frame to check its own latch.
fn probe_at() -> Option<f32> {
static AT: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
*AT.get_or_init(|| {
let v = std::env::var("WOW_MATANIM_PROBE").ok()?;
Some(v.trim().parse().unwrap_or(20.0))
})
}
#[cfg(test)]
mod delta_tests {
use super::*;
@@ -671,6 +671,9 @@ impl Plugin for DoodadAnimPlugin {
(
sample_mat_anim.before(crate::model_render::ModelVisSet),
tick_anim_materials.after(crate::model_render::ModelVisSet),
// The readout of everything that lane just decided (`WOW_MATANIM_PROBE`), after
// it, so the `row` column is this frame's write and not the previous one's.
mat_anim::matanim_probe.after(tick_anim_materials),
),
);
}
@@ -85,6 +85,44 @@ impl<A: Asset> Parked<A> {
self.0.remove(&id);
}
/// The value behind `id` **wherever it currently lives** — the store when the asset exists,
/// the parked half when it does not yet. `None` only when neither holds it (a foreign handle,
/// or one dropped everywhere).
///
/// This is the address a build-time stamp needs. Deferral split "the material a spawner just
/// built" into two homes, and a lane that reaches for the store alone reads a *live feature*
/// as an absent asset: the mat-anim registration wrote its table slot with
/// `Assets::get_mut`, got `None` for every deferred material, and silently froze every
/// UV-scroll and animated-tint batch in the world (decision 2038). Realizing the asset to
/// stamp it would work and would also throw away exactly the residency deferral buys; the
/// stamp belongs in the value, not in the store it happens to be sitting outside of.
pub fn value_mut<'a>(
&'a mut self,
store: &'a mut Assets<A>,
id: AssetId<A>,
) -> Option<&'a mut A> {
// Store first: once realized it is the truth, and a parked value for a live id could
// only be a stale leftover.
if store.contains(id) {
return store.get_mut(id);
}
self.0.get_mut(&id)
}
/// [`Self::value_mut`]'s read-only twin — for a reader that must not dirty the asset
/// (`Assets::get_mut` marks it `Modified`, which is a bind-group rebuild on the Metal
/// non-bindless path: the exact cost 1381 removed from this lane).
pub fn value<'a>(&'a self, store: &'a Assets<A>, id: AssetId<A>) -> Option<&'a A> {
store.get(id).or_else(|| self.0.get(&id))
}
/// Either half holds a value for `id` — "this material is alive", the deferral-aware form of
/// `Assets::contains`. A registry that evicts on the store alone drops every entry whose
/// material is merely parked.
pub fn holds(&self, store: &Assets<A>, id: AssetId<A>) -> bool {
store.contains(id) || self.0.contains_key(&id)
}
/// Realize every parked value some *visible* binding names. `bound` yields each binding
/// with its view-visibility (`None` = not on the visibility lane at all — a booth part
/// before its camera, a test world — which counts as visible: bound is enough).
@@ -142,6 +180,44 @@ pub fn realize_all(materials: &mut Assets<WowModelMaterial>) {
with_pending(|p| p.realize_all(materials));
}
/// Apply `f` to a built material **wherever it lives** — see [`Parked::value_mut`]. `None` when
/// neither the store nor the parked half holds `id`.
///
/// The write-side counterpart of [`realize`], and the right call for a **one-shot build-time
/// stamp**: a lane that must leave a mark on a material it just built, without forcing the
/// material into the store (which is what deferral exists to avoid) and without caring whether
/// something has drawn it yet. The closure form is not a style choice — the parked table lives
/// behind a mutex, so a `&mut` into it cannot leave the lock.
pub fn with_material_mut<R>(
materials: &mut Assets<WowModelMaterial>,
id: AssetId<WowModelMaterial>,
f: impl FnOnce(&mut WowModelMaterial) -> R,
) -> Option<R> {
with_pending(|p| p.value_mut(materials, id).map(f))
}
/// Read a built material **wherever it lives** — [`with_material_mut`]'s non-dirtying twin
/// ([`Parked::value`]). What a readout wants: a probe that reached for the store alone would
/// report a parked material's fields as absent and reproduce the very confusion it exists to
/// resolve.
pub fn with_material<R>(
materials: &Assets<WowModelMaterial>,
id: AssetId<WowModelMaterial>,
f: impl FnOnce(&WowModelMaterial) -> R,
) -> Option<R> {
with_pending(|p| p.value(materials, id).map(f))
}
/// Either half holds a value for `id` — see [`Parked::holds`]. The liveness test a registry
/// keyed by material asset id must evict on; `Assets::contains` alone reads a parked material as
/// dead.
pub fn holds(materials: &Assets<WowModelMaterial>, id: AssetId<WowModelMaterial>) -> bool {
// The store answers without the lock, and a REALIZED material is the common case for
// everything a per-frame lane asks about (only a parked one can be undrawn), so the mutex is
// reached for exactly the entries the store cannot settle.
materials.contains(id) || with_pending(|p| p.holds(materials, id))
}
/// How many values are parked — the census figure beside `mats=`.
pub fn pending_len() -> usize {
with_pending(|p| p.len())
@@ -192,6 +268,43 @@ mod tests {
assert!(!parked.realize(&mut store, stray.id()));
}
/// **The law decision 2038 was written from**: a value that is only PARKED is still a live
/// material — writable in place, and `holds`-alive — so a lane that stamps a material it just
/// built lands its mark whether or not anything has drawn it yet, and a registry keyed by
/// asset id does not evict it as dead. Reaching for the store alone is what silently froze
/// every UV-scroll and animated-tint batch in the world.
#[test]
fn a_parked_value_is_writable_and_counts_as_held() {
let mut store = Assets::<Stub>::default();
let mut parked = Parked::default();
let deferred = parked.defer(&store, Stub(1));
// The store does not have it — the read that used to answer "no such material".
assert!(!store.contains(deferred.id()));
assert!(store.get_mut(deferred.id()).is_none());
// …and both deferral-aware reads do.
assert!(parked.holds(&store, deferred.id()));
assert_eq!(parked.value(&store, deferred.id()).map(|s| s.0), Some(1));
parked
.value_mut(&mut store, deferred.id())
.expect("the parked value is addressable")
.0 = 7;
// The stamp survives realization: the value goes into the store as written.
assert!(parked.realize(&mut store, deferred.id()));
assert_eq!(store.get(&deferred).map(|s| s.0), Some(7));
// Realized, the store is the half that answers — and still the same value.
assert_eq!(parked.value(&store, deferred.id()).map(|s| s.0), Some(7));
parked
.value_mut(&mut store, deferred.id())
.expect("realized values stay addressable")
.0 = 9;
assert_eq!(store.get(&deferred).map(|s| s.0), Some(9));
// An id neither half holds is the only `None` — and the only not-held.
let stray = store.reserve_handle();
assert!(!parked.holds(&store, stray.id()));
assert!(parked.value(&store, stray.id()).is_none());
assert!(parked.value_mut(&mut store, stray.id()).is_none());
}
#[test]
fn bound_and_visible_realizes_in_the_walk_hidden_does_not() {
let mut store = Assets::<Stub>::default();
+30 -1
View File
@@ -42,6 +42,35 @@ const VIEW_START: (f32, f32) = SPAWN_XY;
/// Height above the spawn point the camera opens at, in yards.
const VIEW_START_HEIGHT: f32 = 60.0;
/// `WOW_WORLDVIEW_AT=<x>,<y>[,<z>]` — **open the viewer somewhere else** (raw WoW coords; `z`
/// defaults to [`VIEW_OPEN_Z`]). The viewer is the only **live, server-less** run we have — a real
/// clock, real streaming, no login — and it was pinned to Northshire, so every question that needs
/// time to pass at a named spot had to go through the capture harness, which freezes the clock by
/// design. That is the gap decision 2038 was diagnosed across: the registration half was
/// answerable in a capture, the per-frame tick was not, at any position but one.
///
/// Unparseable or absent leaves the viewer at [`VIEW_START`] — the enforcer's own anchor, which
/// must stay the default (the two binaries stream the same tiles, and a difference between them is
/// a difference in the engine).
fn view_start() -> [f32; 3] {
let Some(v) = std::env::var("WOW_WORLDVIEW_AT").ok() else {
return [VIEW_START.0, VIEW_START.1, VIEW_OPEN_Z];
};
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
match c.len() {
2 => [c[0], c[1], VIEW_OPEN_Z],
3 => [c[0], c[1], c[2]],
_ => {
warn!("worldview: WOW_WORLDVIEW_AT wants `x,y[,z]` — staying at the default anchor");
[VIEW_START.0, VIEW_START.1, VIEW_OPEN_Z]
}
}
}
/// The look-at point's height when `WOW_WORLDVIEW_AT` names no `z` — high enough over Northshire
/// to clear the valley floor, which is what the viewer has always opened above.
const VIEW_OPEN_Z: f32 = 100.0;
/// Near plane, in yards — the client's ([`crate::view::CAM_NEAR`]) value, kept in step by hand
/// until the camera itself moves engine-side (decision 1160, stage zero).
const NEAR: f32 = 0.1;
@@ -317,7 +346,7 @@ struct ViewCam {
}
fn spawn_view_camera(mut commands: Commands, msaa: Res<crate::view::MsaaSetting>) {
let start = wow_to_bevy([VIEW_START.0, VIEW_START.1, 100.0]);
let start = wow_to_bevy(view_start());
let far = crate::view::CAM_FAR;
commands.spawn((
Camera3d::default(),