- the player, party, target and pet frames are the reference's own files (`BuffFrame`, `UnitFrame`, `CombatFeedback`, `PlayerFrame`, `PartyFrame`, `TargetFrame`, `PetFrame`, in the reference's toc order) — our 2741-line `UnitFrames.xml` and our `PartyFrame.xml` are deleted; four engine verbs built for them (`PartialPlayTime`, `NoPlayTime`, `UnitPlayerControlled`, `UnitIsCivilian`, each answering 1/nil), the whole-UI load bracketed in the counted sound-suppression scope `0x48fbf0` self-brackets (the reference fires a `PlaySound` during load and throws it away), and `GetBillingTimeRested` read off `SMSG_AUTH_RESPONSE`'s third billing field rather than hardcoded; the differences the stock files bring are pinned rather than papered — `PlayerFrame` never hides, an unresolved name is BLANK not "Unknown", the target frame has no numerals at all (a bar hover pops the unit tooltip instead) - what the swap surfaced, each a hard break under the stock files: `UnitBuff`/`UnitDebuff` returned the Classic Era ten-value tuple, name first, where 1.12 returns two and three values TEXTURE first (`0x519500`/`0x5198f0`) — a raise twice per `UNIT_AURA` on any debuffed target, and all 184 corpus call sites bind the 1.12 shape; the power events are named per resource (`UNIT_MANA`/`RAGE`/`FOCUS`/`ENERGY`/`HAPPINESS` and their MAX twins) and we fired the Era pair, so no mana, rage or energy bar could live-update; `SetRaidTarget` is the engine verb and `SetRaidTargetIcon` the FrameXML toggle wrapper — we had registered the wrapper's name on the engine's body, so every raid mark in the unit popup raised - the underwater drift cloud: the reference draws a fixed 4000-mote field whenever the camera eye is inside a liquid — not a particle system, nothing spawns, ages or dies; a population scattered once into a 30 yd camera-relative cube, advected by the camera's own motion plus a gust and wrapped, so the field stands still in the world; benilla had the murk and nothing in it; a dry frame commits no draw, and the submerged one is bounded by the reference's own 666-quad cap (633–644 of 4000 measured live, which is the expected survivor count of a uniform cube through the 90° cone — an independent check on population, distribution and cone at once) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2182 lines
110 KiB
Rust
2182 lines
110 KiB
Rust
//! The app-side **unit snapshot + event feed** (decision 0068 §3): the bridge that turns live ECS
|
||
//! game state into the plain data the engine-free `Unit*` Lua bindings read, and into the WoW events
|
||
//! that drive a frame's `OnEvent`.
|
||
//!
|
||
//! The architecture is deliberate (decisions 0006/0061): the Lua game-state API must **not** reach
|
||
//! into the ECS. Instead this runs each frame, *before* the VM's tick/event dispatch
|
||
//! ([`crate::ui_script::UiInput`]), and pushes a [`UnitState`] snapshot for each unit token into the
|
||
//! VM via [`UiScript::set_unit`]. The `"player"` token reads our own avatar's [`ObjectStore`] (tagged
|
||
//! [`SelfPlayer`]); `"target"` reads the [`Selection`]'s entity. Both are found by their ECS entity,
|
||
//! not by re-deriving a guid — the ECS already owns the guid↔entity map. `"targettarget"` is the one
|
||
//! token here that IS reached by a guid: the target's own `UNIT_FIELD_TARGET`, looked up in the same
|
||
//! index (decision 1576).
|
||
//!
|
||
//! Names come from the [`crate::names::NameCache`] (the 1.12 wire has no descriptor names — the
|
||
//! query-cache seam): the feed resolves each token's guid, which asks the server once on a miss and
|
||
//! fills in a later frame; the transition fires `UNIT_NAME_UPDATE` so frames repaint.
|
||
//!
|
||
//! The event surface is fired per field on transitions: `UNIT_HEALTH`/`UNIT_MAXHEALTH`/
|
||
//! `UNIT_LEVEL` (arg1 = token), the **1.12** per-resource power pair `UNIT_MANA`/`UNIT_RAGE`/
|
||
//! `UNIT_FOCUS`/`UNIT_ENERGY`/`UNIT_HAPPINESS` and their five `UNIT_MAX*` twins (arg1 = token; the
|
||
//! resource is in the NAME, not an argument — decision 1819, which retired the Era
|
||
//! `UNIT_POWER_UPDATE`/`UNIT_MAXPOWER` pair that no 1.12 frame listens for),
|
||
//! `UNIT_DISPLAYPOWER` (power *type* changed), `UNIT_NAME_UPDATE`, plus
|
||
//! `PLAYER_ENTERING_WORLD` once and `PLAYER_TARGET_CHANGED` on selection change. A token appearing
|
||
//! counts as a transition of every present field (frames also pull on target change, so either path
|
||
//! populates).
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_formats::ChrClasses;
|
||
use benilla_protocol::messages::ObjectType;
|
||
use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay};
|
||
|
||
use crate::names::NameCache;
|
||
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
|
||
use crate::target::{ring_reaction, Factions, Selection};
|
||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||
use crate::ui_script::{gate, UiInput};
|
||
|
||
/// The feed pass — runs **after [`benilla_world::schedule::WorldStage::Net`]** (the feeds snapshot state
|
||
/// the net apply writes; unordered, `apply_net_updates` could land BETWEEN two feeds, and a
|
||
/// synchronous event fired by the later one then re-read the earlier one's pre-mutation push —
|
||
/// the spellbook's cooldown pie stayed cold until a manual reopen, reproduced live 2026-07-31)
|
||
/// and before [`UiInput`], so the snapshot + events it produces are in place when the VM ticks
|
||
/// and dispatches this frame. A named set so the demo override ([`crate::ui_script`]) can order
|
||
/// itself after it. Configured in [`UiUnitPlugin`] — the set's home.
|
||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub(crate) struct UnitFeed;
|
||
|
||
/// One combat occurrence over a unit — the `UNIT_COMBAT` event feed (decision 0576: the portrait
|
||
/// hit indicator's wire; the shipped `CombatFeedback.lua` is the consumer). **§5-verified**
|
||
/// (wow-re `object-layer/scratch/unit-combat-event-law.md`): the one emitter `0x494600` fires
|
||
/// `(token, action, descriptor, amount, type)` once per live token the unit maps to, with **no
|
||
/// self-suppression and no cvar gate** — the worldtext Gate A's inverse. `type` (arg5) is the
|
||
/// damage school on the melee/spell-damage paths; the miss and heal wrappers hard-code 0. The
|
||
/// melee victim event is **deferred to the swing impact keyframe** (it rides inside `0x6243e0`,
|
||
/// reached only from the `0x624530` victim dispatcher — C2 CONFIRMED). ENERGIZE never fires in
|
||
/// 5875 (string absent binary-wide). Producers: melee at [`melee_unit_combat`], spells/heals at
|
||
/// packet receive (`net/apply/combat_log.rs`). Consumed by [`fire_unit_combat`], which resolves
|
||
/// the entity to its live unit tokens.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct UnitCombatFeedback {
|
||
pub(crate) unit: Entity,
|
||
/// `arg2` — the action: `WOUND`/`MISS`/`DODGE`/`PARRY`/`BLOCK`/`EVADE`/`IMMUNE`/`DEFLECT`/
|
||
/// `RESIST`/`ABSORB`/`REFLECT`/`HEAL`/`ENERGIZE`.
|
||
pub(crate) action: &'static str,
|
||
/// `arg3` — the descriptor: `CRITICAL`/`CRUSHING`/`GLANCING`/`ABSORB`/`BLOCK`/`RESIST`, or `""`.
|
||
pub(crate) flags: &'static str,
|
||
/// `arg4` — the amount (damage/heal/energize; 0 for pure words).
|
||
pub(crate) amount: u32,
|
||
/// `arg5` — the school int (0 = physical; the Lua's `type > 0` draws the number spell-yellow).
|
||
pub(crate) school: u32,
|
||
}
|
||
|
||
/// One center-combat-text message — the `COMBAT_TEXT_UPDATE` event feed (decision 0578; the
|
||
/// Blizzard_CombatText transcription is the consumer). **§5-verified** (wow-re
|
||
/// `playername/scratch/combat-text-update-emission-law.md`): event id 0x21E, fired via the
|
||
/// formatted SignalEvent `0x703f50` from the UnitCombatLog_C.cpp emit helpers — every producer
|
||
/// fires **at packet parse** (the melee one too: `0x6255b0 → 0x629d30`, one call stack — NOT the
|
||
/// impact-keyframe deferral, which belongs to the worldtext/UNIT_COMBAT victim dispatch).
|
||
/// `message_type` is the addon's vocabulary (`DAMAGE`/`DAMAGE_CRIT`/`SPELL_DAMAGE`/`HEAL`/…);
|
||
/// `data`/`extra` mirror `arg2`/`arg3` (all strings on the real wire — the fmt is `"%s..%d.."`).
|
||
/// Producers gate on the SELF recipient — the ref's emit is co-gated with the chat combat-log
|
||
/// category scope (participants beyond self CAN fire it there); the exact participant rule is an
|
||
/// open residual (decision 0580), and self-only is the display-equivalent conservative cut.
|
||
#[derive(Message, Clone)]
|
||
pub(crate) struct CombatTextEvent {
|
||
pub(crate) message_type: &'static str,
|
||
pub(crate) data: Option<String>,
|
||
pub(crate) extra: Option<String>,
|
||
}
|
||
|
||
/// The feed's change-tracking memory: what we last told the VM, plus one server-side log-once.
|
||
///
|
||
/// The VM half lives behind a [`crate::ui_script::VmMemo`], **inside the resource** — the same
|
||
/// law 1290 wrote for `Local` memos, reached the way a `ResMut` system has to reach it: a memory
|
||
/// about what THIS VM was told is unreadable against the next VM, so a `/reload` (1291) — which
|
||
/// replaces the VM without despawning the world — re-fires `PLAYER_ENTERING_WORLD` and re-runs
|
||
/// every transition diff exactly as a fresh login does. Before this, every one of these fields
|
||
/// survived the reload and the new VM never heard the events (the logout path re-armed off the
|
||
/// self descriptor despawning, which a reload never does).
|
||
#[derive(Resource, Default)]
|
||
struct UnitFeedState {
|
||
/// What we last told the VM — dies with the VM it was told to.
|
||
vm: crate::ui_script::VmMemo<UnitFeedMemo>,
|
||
/// Whether we have already warned that our own faction template names no side (decision 0657).
|
||
/// Re-arms when a side resolves again, so a `.gm on` / `.gm off` cycle logs once each way.
|
||
/// **Server memory, not VM memory** — deliberately outside the memo: a `/reload` must not
|
||
/// re-log the GM-mode warning.
|
||
warned_sideless: bool,
|
||
}
|
||
|
||
/// The per-VM half of [`UnitFeedState`] — the event-trigger diffs.
|
||
#[derive(Default)]
|
||
struct UnitFeedMemo {
|
||
/// The gate's counter memories (1439) — the two lazy caches this feed resolves through
|
||
/// (their per-frame `&mut` misses poison `is_changed`, the counters carry the landings).
|
||
names_generation: gate::Watch,
|
||
guild_generation: gate::Watch,
|
||
/// Whether `PLAYER_ENTERING_WORLD` has been fired (once per world entry, once per VM).
|
||
entered_world: bool,
|
||
/// Per token, the last snapshot we pushed — the per-field event triggers diff against it.
|
||
last: HashMap<String, UnitState>,
|
||
/// The last selection guid, for the `PLAYER_TARGET_CHANGED` trigger.
|
||
target_guid: Option<u64>,
|
||
/// The last `(PLAYER_XP, PLAYER_NEXT_LEVEL_XP)` pair pushed, for the `PLAYER_XP_UPDATE` trigger —
|
||
/// the XP bar's feed is a player-global (like coinage), not a per-unit-token field.
|
||
last_xp: Option<(u32, u32)>,
|
||
/// The last `(restState, restPool, PLAYER_FLAGS)` triple pushed, for the `UPDATE_EXHAUSTION`
|
||
/// and `PLAYER_UPDATE_RESTING` triggers — player-globals like the XP pair (decisions
|
||
/// 1082/1087). The whole flags dword, not just the resting bit: the client's `0x5ee990`
|
||
/// fires `PLAYER_UPDATE_RESTING` on any PLAYER_FLAGS delta. Pushed as one snapshot so
|
||
/// `GetRestState`/`GetXPExhaustion`/`IsResting` never read it half-updated.
|
||
last_rest: Option<(u8, u32, u32)>,
|
||
/// Our avatar's last-seen `UNIT_FIELD_LEVEL`, for the `PLAYER_LEVEL_UP` trigger (decision
|
||
/// 1094). `None` until first seen — the first sighting is the login descriptor, not a ding.
|
||
last_level: Option<u32>,
|
||
/// The last `(count, banked-target guid)` pair pushed, for the `PLAYER_COMBO_POINTS` trigger —
|
||
/// player-globals (`PLAYER_FIELD_BYTES` byte 1 + `PLAYER_FIELD_COMBO_TARGET`), not per-unit-
|
||
/// token fields. Diffed as a pair because the server writes them as one (decision 0875).
|
||
last_combo: Option<(u8, u64)>,
|
||
/// The self unit's last in-combat flag, for the `PLAYER_REGEN_DISABLED`/`ENABLED` triggers
|
||
/// (`None` until first seen — first sight fires only when already IN combat, so logging in
|
||
/// at peace never announces "Leaving Combat").
|
||
in_combat: Option<bool>,
|
||
/// The self unit's last `PLAYER_FLAGS_PVP_DESIRED` bit, for the toggle announcement
|
||
/// (decision 0652). `None` until first seen: the reference reacts to a *changed*-bits mask, so
|
||
/// the descriptor that first carries the flag at login announces nothing.
|
||
pvp_desired: Option<bool>,
|
||
/// The self unit's last `(HIDE_HELM, HIDE_CLOAK)` pair, for the worn-display push (decision
|
||
/// 1472). Pushed on the **edge** and never per frame: the Options row's setter flips the VM's
|
||
/// belief optimistically and the server's answer is a round trip away, so a per-frame push
|
||
/// would snap the value back to the stale descriptor in between.
|
||
worn_hidden: Option<(bool, bool)>,
|
||
/// The self player's last-pushed `PLAYER_FIELD_BYTES` byte 2 — the four extra bars' visibility
|
||
/// (wow-re `action-bar-toggles.md`). A player-global like the combo pair, pushed on the edge.
|
||
/// `None` until first seen; there is no event to fire on it, because the real client registers
|
||
/// no field-change callback anywhere near this offset.
|
||
action_bar_toggles: Option<u8>,
|
||
}
|
||
|
||
/// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only
|
||
/// supplies their data (and the events) from ECS state.
|
||
pub(crate) struct UiUnitPlugin;
|
||
|
||
impl Plugin for UiUnitPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.configure_sets(
|
||
Update,
|
||
UnitFeed
|
||
.after(benilla_world::schedule::WorldStage::Net) // the set's own doc: the why
|
||
// …and never before the in-game UI exists (1348). The whole SET, not just
|
||
// `feed_units`: every feed in it either fires a login one-shot or latches a
|
||
// per-VM memo, and both are lost forever against the boot VM. The window and
|
||
// the reference's own ordering: `ui_script::ingame_ui_pending`.
|
||
.run_if(bevy::ecs::schedule::common_conditions::not(
|
||
crate::ui_script::ingame_ui_pending,
|
||
)),
|
||
)
|
||
.init_resource::<UnitFeedState>()
|
||
.add_message::<UnitCombatFeedback>()
|
||
.add_message::<CombatTextEvent>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
feed_units,
|
||
feed_unit_reach,
|
||
melee_unit_combat,
|
||
fire_unit_combat,
|
||
fire_combat_text,
|
||
)
|
||
.chain()
|
||
.in_set(UnitFeed)
|
||
.before(UiInput),
|
||
)
|
||
.add_systems(Update, drain_pvp_toggles.after(UiInput))
|
||
.add_systems(Update, drain_worn_display_toggles.after(UiInput))
|
||
.add_systems(Update, drain_action_bar_toggles.after(UiInput))
|
||
.add_systems(Update, feed_default_language.in_set(UnitFeed))
|
||
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290);
|
||
// `load_default_languages` only builds a Bevy resource and stays a one-shot.
|
||
.add_systems(Update, load_exhaustion_rows)
|
||
.add_systems(PostStartup, load_default_languages);
|
||
}
|
||
}
|
||
|
||
/// The race → default-chat-language join, loaded once ([`benilla_formats::DefaultLanguages`]).
|
||
/// Absent when the tables would not load — `GetDefaultLanguage()` then answers the reference's own
|
||
/// zero-values shape rather than a made-up word.
|
||
#[derive(Resource)]
|
||
pub(crate) struct DefaultLanguagesRes(pub(crate) benilla_formats::DefaultLanguages);
|
||
|
||
/// Load `Languages.dbc` × `ChrRaces.dbc` once at startup ([`load_exhaustion_rows`]'s shape).
|
||
fn load_default_languages(
|
||
mut commands: Commands,
|
||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||
) {
|
||
let Some(assets) = assets else { return };
|
||
let loaded = {
|
||
use benilla_assets::LockRecover;
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_default_languages(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(langs) => {
|
||
info!("ui_unit: {} race → default-language rows", langs.len());
|
||
commands.insert_resource(DefaultLanguagesRes(langs));
|
||
}
|
||
// Not fatal: the binding's contract already has an answer for "no table".
|
||
Err(e) => warn!("ui_unit: default languages unavailable — {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// Push `GetDefaultLanguage()`'s one string, on change only.
|
||
///
|
||
/// The reference resolves it per call from the live player object; we resolve it once per race
|
||
/// change, which is the same answer with none of the per-frame churn — a race cannot change
|
||
/// without a new world entry. `None` (no player object, or no table) is the reference's zero-value
|
||
/// state, and that is what the VM stores.
|
||
///
|
||
/// **The locale column is 0.** `[0xc0e080]` is the client's locale slot; only enUS is populated in
|
||
/// the 5875 data, and every other DBC catalog here reads column 0 for the same reason.
|
||
fn feed_default_language(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||
langs: Option<Res<DefaultLanguagesRes>>,
|
||
mut pushed: Local<crate::ui_script::VmMemo<Option<Option<String>>>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let pushed = pushed.get(&script);
|
||
let name = self_q
|
||
.iter()
|
||
.next()
|
||
.and_then(|store| store.0.unit_race())
|
||
.zip(langs.as_ref())
|
||
.and_then(|(race, langs)| langs.0.name(u32::from(race), 0))
|
||
.map(str::to_string);
|
||
if pushed.as_ref() != Some(&name) {
|
||
script.set_default_language(name.clone());
|
||
*pushed = Some(name);
|
||
}
|
||
}
|
||
|
||
/// Seed the VM's Exhaustion.dbc table once per VM — the rest bindings' data
|
||
/// ([`benilla_ui::script::UiScript::set_exhaustion_rows`]; the ui_macro icon-catalog shape).
|
||
/// A failed load keeps the model's shipped-table fallback, so the rest surface still behaves
|
||
/// like the shipped enUS client rather than going dark.
|
||
///
|
||
/// Per VM rather than per process (1290) for the reason every seed here is: a login builds a fresh
|
||
/// VM, and this one degrades quietly — the fallback table is close enough that nobody would notice
|
||
/// it had stopped being seeded.
|
||
fn load_exhaustion_rows(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||
mut seeded: Local<crate::ui_script::VmMemo<bool>>,
|
||
) {
|
||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||
return;
|
||
};
|
||
if !seeded.claim(&script) {
|
||
return;
|
||
}
|
||
let loaded = {
|
||
use benilla_assets::LockRecover;
|
||
let mut chain = assets.chain.lock_recover();
|
||
benilla_formats::load_exhaustion(&mut chain)
|
||
};
|
||
match loaded {
|
||
Ok(rows) => {
|
||
info!("ui_unit: {} Exhaustion.dbc rest states", rows.len());
|
||
script.set_exhaustion_rows(
|
||
rows.into_iter()
|
||
.map(|r| (r.id as u8, r.name, f64::from(r.factor)))
|
||
.collect(),
|
||
);
|
||
}
|
||
Err(e) => error!("ui_unit: Exhaustion.dbc failed — shipped-table fallback holds: {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// Melee swing → the `UNIT_COMBAT` vocabulary (§5-verified shape, wow-re
|
||
/// `unit-combat-event-law.md`): the action comes from the melee wrapper's per-victim-state table
|
||
/// (`0x4946d0` → `actionTable@0x83de28`), the descriptor from HitInfo bits **keyed on the
|
||
/// amount's sign** — `amount > 0` picks among CRITICAL `0x80` / GLANCING `0x4000` / CRUSHING
|
||
/// `0x8000`, `amount ≤ 0` among ABSORB `0x20` / BLOCK `0x800` / RESIST `0x40`, else `""`.
|
||
fn melee_feedback(hit_info: u32, victim_state: u32, damage: u32) -> (&'static str, &'static str) {
|
||
match victim_state {
|
||
2 => ("DODGE", ""),
|
||
3 => ("PARRY", ""),
|
||
5 => ("BLOCK", ""),
|
||
6 => ("EVADE", ""),
|
||
7 => ("IMMUNE", ""),
|
||
8 => ("DEFLECT", ""),
|
||
// 0 UNAFFECTED / 1 NORMAL / 4 INTERRUPT: WOUND, descriptor by the amount-sign key.
|
||
_ => {
|
||
if damage > 0 {
|
||
if hit_info & 0x80 != 0 {
|
||
("WOUND", "CRITICAL")
|
||
} else if hit_info & 0x4000 != 0 {
|
||
("WOUND", "GLANCING")
|
||
} else if hit_info & 0x8000 != 0 {
|
||
("WOUND", "CRUSHING")
|
||
} else {
|
||
("WOUND", "")
|
||
}
|
||
} else if hit_info & 0x20 != 0 {
|
||
("WOUND", "ABSORB") // full absorb
|
||
} else if hit_info & 0x800 != 0 {
|
||
("WOUND", "BLOCK") // full block, when the bridge didn't rewrite the state
|
||
} else if hit_info & 0x40 != 0 {
|
||
("WOUND", "RESIST") // full resist
|
||
} else {
|
||
("MISS", "")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The melee `UNIT_COMBAT` producer: rides the swing's impact keyframe ([`SwingImpact`]) with the
|
||
/// rest of the victim feedback — including `text_only` flushes, which the client fires the text
|
||
/// channel for. Every victim qualifies (no Gate A, no source class on the portrait path); token
|
||
/// resolution happens in [`fire_unit_combat`], so a swing on an un-tokened bystander simply
|
||
/// fires nothing. The center combat text does NOT ride here — the client fires it synchronously
|
||
/// at packet parse (§5-corrected, decision 0580; the producer lives in `net/apply/combat.rs`).
|
||
fn melee_unit_combat(
|
||
mut impacts: MessageReader<crate::creature_anim::SwingImpact>,
|
||
mut out: MessageWriter<UnitCombatFeedback>,
|
||
) {
|
||
for crate::creature_anim::SwingImpact { swing: s, .. } in impacts.read() {
|
||
let Some(victim) = s.victim else { continue };
|
||
let (action, flags) = melee_feedback(s.hit_info, s.victim_state, s.damage);
|
||
out.write(UnitCombatFeedback {
|
||
unit: victim,
|
||
action,
|
||
flags,
|
||
amount: s.damage,
|
||
school: 0, // melee is physical (the wire's sub-damage school is not carried — always 0 here)
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Drain [`CombatTextEvent`] into the VM: `COMBAT_TEXT_UPDATE(messageType, data, extra)` — the
|
||
/// arg shape the shipped Blizzard_CombatText `OnEvent` reads (`arg1..arg3`).
|
||
fn fire_combat_text(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
mut events: MessageReader<CombatTextEvent>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for ev in events.read() {
|
||
let arg = |v: &Option<String>| v.clone().map_or(ScriptValue::Nil, ScriptValue::Str);
|
||
script.fire_event(
|
||
"COMBAT_TEXT_UPDATE",
|
||
vec![
|
||
ScriptValue::Str(ev.message_type.to_string()),
|
||
arg(&ev.data),
|
||
arg(&ev.extra),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Drain [`UnitCombatFeedback`] into the VM: fire `UNIT_COMBAT` once per live token the entity
|
||
/// maps to (`"player"`, `"target"` — the same tokens [`feed_units`] feeds). The frames filter by
|
||
/// `arg1` exactly like the real client's; in 1.12 only PlayerFrame/PetFrame register it, so a
|
||
/// `"target"` fire is API surface, not pixels.
|
||
fn fire_unit_combat(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
mut events: MessageReader<UnitCombatFeedback>,
|
||
self_q: Query<(), With<SelfPlayer>>,
|
||
selection: Res<Selection>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for ev in events.read() {
|
||
let mut fire = |token: &str| {
|
||
script.fire_event(
|
||
"UNIT_COMBAT",
|
||
vec![
|
||
ScriptValue::Str(token.to_string()),
|
||
ScriptValue::Str(ev.action.to_string()),
|
||
ScriptValue::Str(ev.flags.to_string()),
|
||
ScriptValue::Int(i64::from(ev.amount)),
|
||
ScriptValue::Int(i64::from(ev.school)),
|
||
],
|
||
);
|
||
};
|
||
if self_q.contains(ev.unit) {
|
||
fire("player");
|
||
}
|
||
if selection.target == Some(ev.unit) {
|
||
fire("target");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The 1.12 race id → (localized display, `raceFile` token) — `UnitRace`'s two returns. The file
|
||
/// token is the client's internal name (undead = `"Scourge"`, the space dropped from `"NightElf"`),
|
||
/// the same vocabulary the 2D portrait stand-in files use (`portrait::temporary_portrait`). The
|
||
/// display column is also what `$R`/`$r` expand to ([`crate::npc_text`]) — one table, both readers.
|
||
pub(crate) fn race_names(race: u8) -> Option<(&'static str, &'static str)> {
|
||
Some(match race {
|
||
1 => ("Human", "Human"),
|
||
2 => ("Orc", "Orc"),
|
||
3 => ("Dwarf", "Dwarf"),
|
||
4 => ("Night Elf", "NightElf"),
|
||
5 => ("Undead", "Scourge"),
|
||
6 => ("Tauren", "Tauren"),
|
||
7 => ("Gnome", "Gnome"),
|
||
8 => ("Troll", "Troll"),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// The 1.12 class id → (localized display, `classFileName`) — `UnitClass`'s two returns. The file
|
||
/// name is uppercase (the ref's `strupper(classFileName)` tooltip lookups index GlobalStrings keys
|
||
/// like `WARRIOR_STRENGTH_TOOLTIP` directly with it).
|
||
pub(crate) fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
|
||
Some(match class {
|
||
1 => ("Warrior", "WARRIOR"),
|
||
2 => ("Paladin", "PALADIN"),
|
||
3 => ("Hunter", "HUNTER"),
|
||
4 => ("Rogue", "ROGUE"),
|
||
5 => ("Priest", "PRIEST"),
|
||
7 => ("Shaman", "SHAMAN"),
|
||
8 => ("Mage", "MAGE"),
|
||
9 => ("Warlock", "WARLOCK"),
|
||
11 => ("Druid", "DRUID"),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// A playable race id → `UnitFactionGroup`'s token (`"Alliance"`/`"Horde"`), or `None` for a race
|
||
/// id that is not one of the eight.
|
||
///
|
||
/// **A side derived from the RACE, not from the faction template** — deliberately, and only for
|
||
/// the one window in which the template does not exist yet. [`faction_group`] is the real answer
|
||
/// and reads `UNIT_FIELD_FACTIONTEMPLATE` off the descriptor; during world entry there is no
|
||
/// descriptor, and `UnitFactionGroup("player")` answering nil there is not "no faction", it is a
|
||
/// state a real player character cannot be in. AceDB-2.0 — embedded across a large slice of the
|
||
/// corpus — builds its per-realm key as `realm .. " - " .. faction` at **file scope**, so a nil
|
||
/// side is 24 corpus addons stopping on `attempt to concatenate local 'faction'`
|
||
/// (`addon_harness::seat_a_session`, decision 1195, which seats exactly this in the survey's VM).
|
||
///
|
||
/// Every playable race has a fixed side in 1.12, so this is a lookup rather than a guess — and it
|
||
/// reads the [`crate::char_create::ALLIANCE`] column the create screen already keeps, so the two
|
||
/// cannot drift apart.
|
||
pub(crate) fn race_faction_group(race: u8) -> Option<&'static str> {
|
||
if !(1..=8).contains(&race) {
|
||
return None;
|
||
}
|
||
Some(if crate::char_create::ALLIANCE.contains(&race) {
|
||
"Alliance"
|
||
} else {
|
||
"Horde"
|
||
})
|
||
}
|
||
|
||
/// Resolve a UnitPopup unit token to the **player guid** it names — `"target"` through the
|
||
/// selection iff it really is a player (the target frame's PLAYER menu), a `"partyN"` token through
|
||
/// the roster (the party frame's PARTY menu). `"player"` (yourself) and any unresolved token answer
|
||
/// `None`.
|
||
///
|
||
/// Shared by every popup verb that acts on another player: trade's `InitiateTrade` (decision 0592
|
||
/// P1) and inspect's `NotifyInspect` (decision 0631) both need exactly this step, so it lives here
|
||
/// rather than once per window.
|
||
pub(crate) fn player_token_guid(
|
||
token: &str,
|
||
selection: &Selection,
|
||
group: &crate::ui_party::GroupState,
|
||
) -> Option<u64> {
|
||
match token {
|
||
"target" => selection
|
||
.guid
|
||
.filter(|g| benilla_protocol::guid::is_player(*g)),
|
||
"player" => None,
|
||
tok => tok
|
||
.strip_prefix("party")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.and_then(|n| n.checked_sub(1))
|
||
.and_then(|n| group.party_slots().nth(n))
|
||
.map(|m| m.guid),
|
||
}
|
||
}
|
||
|
||
/// **The one unit-token resolver** — token → the live object it names, or `None`.
|
||
///
|
||
/// The reference has exactly one of these (`0x515970`, nine `_strnicmp` compares, then the object
|
||
/// manager), and every binding that takes a unit reaches its unit through it. We had three: this
|
||
/// one (inlined in `TargetUnit`'s drain), [`player_token_guid`]'s players-only popup map, and the
|
||
/// per-token legs [`feed_units`] walks. They disagreed, and the disagreement was report **B304** —
|
||
/// the range map was built from the players-only one, so a creature `"target"` never entered it and
|
||
/// `CheckInteractDistance("target", 4)` answered a constant for every mob at every distance.
|
||
///
|
||
/// So: one resolver, and a caller that wants a *typemask* applies it to the resolved unit rather
|
||
/// than narrowing the token (the same order 1564 §2 settled for the reach map's own entries, and
|
||
/// the one `target::by_name`'s follow drain already keeps for the start gate).
|
||
///
|
||
/// [`Selection`] is a parameter rather than a field because [`crate::target::SelectCommit`] holds
|
||
/// it as `ResMut`, and one system cannot take both.
|
||
///
|
||
/// **Not resolved:** `partypetN`/`raidpetN` (no feed holds those units at all — a token nothing
|
||
/// pushes a `UnitState` for would answer a distance for a unit `UnitExists` denies) and `npc`
|
||
/// (the interaction target, which we do not keep). Both are *recognised* by the grammar
|
||
/// ([`benilla_ui::script`]'s `token_recognised`), so they stay a quiet nil, not a raise.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct UnitTokens<'w, 's> {
|
||
/// `Option` for [`feed_units`]' reason: a UI-only harness runs these feeds with no net stack,
|
||
/// and a bare `Res` turns that into a system-validation panic rather than a token that names
|
||
/// nobody. Same for the two below, which belong to the pet and targeting plugins.
|
||
index: Option<Res<'w, crate::net::GuidIndex>>,
|
||
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
|
||
hovered: Option<Res<'w, crate::target::Hovered>>,
|
||
group: Res<'w, crate::ui_party::GroupState>,
|
||
pub(crate) stores: Query<'w, 's, &'static ObjectStore>,
|
||
me: Query<'w, 's, (Entity, &'static Guid), With<SelfPlayer>>,
|
||
}
|
||
|
||
impl UnitTokens<'_, '_> {
|
||
/// Our own body's entity + guid.
|
||
fn me(&self) -> Option<(Entity, u64)> {
|
||
self.me.iter().next().map(|(e, g)| (e, g.0))
|
||
}
|
||
|
||
/// The guid → entity map, when the net stack is present — `0x468460`'s lookup. `pub(crate)`
|
||
/// for the selection drain's `TargetLastEnemy` arm, which starts from a remembered **guid**
|
||
/// rather than a token and must land in the very same index every token arm below does.
|
||
pub(crate) fn held(&self, guid: u64) -> Option<(Entity, u64)> {
|
||
Some((*self.index.as_ref()?.0.get(&guid)?, guid))
|
||
}
|
||
|
||
/// Resolve `token` to the live object it names. Case-folded (`_strnicmp`, 1247), and the
|
||
/// prefix order is the reference's: `raid` before `party` for the same reason its own resolver
|
||
/// puts `partypet` before `party` — the first matching compare wins.
|
||
pub(crate) fn resolve(&self, token: &str, selection: &Selection) -> Option<(Entity, u64)> {
|
||
let token = token.to_ascii_lowercase();
|
||
match token.as_str() {
|
||
"player" => self.me(),
|
||
"target" => selection.target.zip(selection.guid),
|
||
// The target's own target, one hop off `UNIT_FIELD_TARGET` — the read `/assist` and
|
||
// the `"targettarget"` snapshot both run (decision 1576).
|
||
"targettarget" => selection
|
||
.target
|
||
.and_then(|e| self.stores.get(e).ok())
|
||
.and_then(|s| s.0.unit_target())
|
||
.filter(|g| *g != 0)
|
||
.and_then(|g| self.held(g)),
|
||
// The hovered unit — the same `Hovered` pick `ui_tooltip` pushes `"mouseover"` from,
|
||
// so `UnitExists("mouseover")` and this can never disagree.
|
||
"mouseover" => {
|
||
let h = self.hovered.as_ref()?;
|
||
h.target.zip(h.guid)
|
||
}
|
||
// Off the bar's cached guid, the word `"pet"`'s snapshot and `UNIT_PET` read.
|
||
"pet" => {
|
||
let guid = self.pet.as_ref()?.spells.pet_guid;
|
||
(guid != 0).then(|| self.held(guid)).flatten()
|
||
}
|
||
t if t.starts_with("raid") => t
|
||
.strip_prefix("raid")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.filter(|n| (1..=40).contains(n))
|
||
.and_then(|n| {
|
||
crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), n)
|
||
})
|
||
.and_then(|g| self.held(g)),
|
||
t => t
|
||
.strip_prefix("party")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.filter(|n| (1..=4).contains(n))
|
||
.and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid))
|
||
.and_then(|g| self.held(g)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The tokens [`feed_unit_reach`] offers the map each frame — every token [`UnitTokens`] can
|
||
/// resolve, reusing the party feed's own two tables so this set can never name a row that feed
|
||
/// does not push a `UnitState` for. `"player"` is in it because the reference answers it too
|
||
/// (d² = 0, in range of everything); the popup rows that started this map need only the first few.
|
||
fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||
["player", "target", "targettarget", "mouseover", "pet"]
|
||
.into_iter()
|
||
.chain(crate::ui_party::PARTY_TOKENS)
|
||
.chain(crate::ui_party::RAID_TOKENS)
|
||
}
|
||
|
||
/// The squared distance between two world positions, in the binary's own accumulation shape: `f32`
|
||
/// inputs widened to `f64`, summed `(dz² + dx²) + dy²` (wow-re's transcription of
|
||
/// `0x48a26f..0x48a27d`, the kernel `caninspect_dist2` and `check_interact_dist2` share).
|
||
///
|
||
/// Our axes are Bevy's rather than the client's WoW triple. d² is invariant under that rotation, so
|
||
/// the only conceivable divergence from the binary is a last-ulp one — which can only change the
|
||
/// verdict for a unit standing *exactly* on a threshold. The thresholds and comparison operators,
|
||
/// which are what actually decide each gate, are transcribed exactly at the two bindings
|
||
/// (`benilla_ui::script::inspect`).
|
||
fn dist_sq(q: Vec3, p: Vec3) -> f64 {
|
||
let dx = f64::from(q.x) - f64::from(p.x);
|
||
let dy = f64::from(q.y) - f64::from(p.y);
|
||
let dz = f64::from(q.z) - f64::from(p.z);
|
||
(dz * dz + dx * dx) + dy * dy
|
||
}
|
||
|
||
/// Feed the **unit reach map** — for every token that resolves to a live unit object, its squared
|
||
/// distance from us, plus whether that unit passes inspect's own two non-distance refusals.
|
||
///
|
||
/// **Membership is the object lookup and nothing else** (1564 §2). An absent token is one the
|
||
/// object manager holds no unit for, and both bindings answer `nil` there — the reference's own
|
||
/// null-object arm. Every typemask therefore rides IN the entry:
|
||
///
|
||
/// - *attackable* — vmangos's inspect refusal (`IsValidAttackTarget`, `MiscHandler.cpp:945-956`).
|
||
/// Keeping the token out of the map instead would make `CheckInteractDistance` — a pure distance
|
||
/// test in the binary — gray Follow and Duel on an enemy player the reference leaves live (B316).
|
||
/// - *is a player* — vmangos's other one (`sObjectMgr.GetPlayer`). This is the leg that was applied
|
||
/// at the token and was report **B304**: a creature `"target"` never entered the map, so the
|
||
/// 30-yard rung Quiver's Dead Zone band reads answered a constant for every mob — "in range" at
|
||
/// the published tag, "out of range" after 1564 flipped the absent default. Both are the same
|
||
/// defect: the map was never asked about mobs at all.
|
||
///
|
||
/// That the *client* checks those two is INFERRED (the 348-byte `0x48a1b0`'s non-math part isn't in
|
||
/// the RE record), but a wrong guess can only cost a request the server would drop.
|
||
///
|
||
/// Ungated, unlike every other feed here (1439): the numbers change whenever anything moves, so a
|
||
/// change gate would fire every frame anyway and cost a comparison for nothing. Nothing keys an
|
||
/// event off this map, so a re-push is invisible.
|
||
fn feed_unit_reach(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
tokens: UnitTokens,
|
||
selection: Res<Selection>,
|
||
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
|
||
transforms: Query<&Transform>,
|
||
factions: Option<Res<Factions>>,
|
||
reputations: Res<Reputations>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
let mut reach = HashMap::new();
|
||
if let Some((self_tf, self_store)) = self_q.iter().next() {
|
||
for token in reach_tokens() {
|
||
let Some((entity, guid)) = tokens.resolve(token, &selection) else {
|
||
continue;
|
||
};
|
||
let Ok(tf) = transforms.get(entity) else {
|
||
continue;
|
||
};
|
||
let store = tokens.stores.get(entity).ok();
|
||
let inspectable = benilla_protocol::guid::is_player(guid)
|
||
&& !crate::target::can_attack(
|
||
store,
|
||
factions.as_deref(),
|
||
&reputations,
|
||
Some(self_store),
|
||
);
|
||
reach.insert(
|
||
token.to_string(),
|
||
benilla_ui::script::UnitReach {
|
||
dist_sq: dist_sq(tf.translation, self_tf.translation),
|
||
inspectable,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
script.set_unit_reach(reach);
|
||
}
|
||
|
||
/// The object stores [`feed_units`] reads, and the change tracking over them.
|
||
///
|
||
/// One `SystemParam` rather than three, because Bevy's tuple limit is 16 and this feed had grown
|
||
/// to sit exactly on it — the next resource it legitimately needed (`ChrClasses.dbc`, for
|
||
/// `UnitHasRelicSlot`) turned the ceiling into a compile error with no hint that a ceiling was
|
||
/// what it hit. Grouping the three that always move together buys the room back and says why
|
||
/// they belong together, which the flat list did not. Same idiom as
|
||
/// [`crate::target::lock::GoLockInputs`].
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct UnitStores<'w, 's> {
|
||
/// Every streamed object's descriptor, read by guid → entity.
|
||
all: Query<'w, 's, &'static ObjectStore>,
|
||
/// Whose descriptor moved this frame — the feed's dirty gate.
|
||
changed: Query<'w, 's, (), Changed<ObjectStore>>,
|
||
/// Whose object left the manager — the other half of that gate, and cleared every run.
|
||
removed: RemovedComponents<'w, 's, ObjectStore>,
|
||
}
|
||
|
||
/// Build a unit snapshot from a streamed object's descriptor (decision 0061's `ObjectFields`) plus
|
||
/// its cache-resolved name and its `UnitReaction` value (`1..8`, or `0` for tokens whose reaction we
|
||
/// don't resolve — everything but `"target"`; see [`feed_units`]).
|
||
///
|
||
/// `classes` is `ChrClasses.dbc`, absent when the client data failed to load. Only the relic column
|
||
/// is read off it — the class NAMES are this file's own table, because they are localized display
|
||
/// strings rather than a DBC column we can key on.
|
||
pub(crate) fn snapshot(
|
||
store: &ObjectStore,
|
||
name: Option<String>,
|
||
reaction: u8,
|
||
classes: Option<&ChrClasses>,
|
||
) -> UnitState {
|
||
let power_type = store.0.unit_power_type();
|
||
let race = store.0.unit_race().and_then(race_names);
|
||
let class_id = store.0.unit_class();
|
||
let class = class_id.and_then(class_names);
|
||
UnitState {
|
||
exists: true,
|
||
// **This function IS the reference's `0x468460` having succeeded.** It is only ever called
|
||
// with a live `ObjectStore`, so every snapshot it builds is a unit the object manager holds
|
||
// — which is the whole of `UnitIsVisible` (wow-re
|
||
// `ui/scratch/unitisvisible-object-presence.md`). Set here rather than at the call sites so
|
||
// a future feed cannot forget it: the roster-only legs build a `UnitState` literally and
|
||
// get `false` from `Default`, which is the correct out-of-range answer.
|
||
has_object: true,
|
||
name,
|
||
// The UI-facing health/power getters, not the raw fields (decision 1022): a unit carrying
|
||
// `UNIT_DYNFLAG_DEAD` — a feigning hunter — answers 0 to `UnitHealth 0x5174d0` and
|
||
// `UnitMana 0x517670`, while the *max* getters stay ungated, so its bars read 0/max (empty)
|
||
// for itself and for everyone watching. The zeroes ride the ordinary per-field diff in
|
||
// [`fire_transitions`], which fires `UNIT_HEALTH` + the power event on the flag's edge —
|
||
// exactly the pair the reference's `UNIT_DYNAMIC_FLAGS` watcher fires there
|
||
// (`0x6004c5`/`0x6004f0`, event `0x10` and `0x11 + powerType`). The power pair also carries
|
||
// the raw→display divide the same two getters do (decision 1034) — rage rides the wire ×10
|
||
// and pet happiness ×1000, so these are the numbers the reference shows, not the wire's.
|
||
health: store.0.unit_shown_health().unwrap_or(0),
|
||
max_health: store.0.unit_max_health().unwrap_or(0),
|
||
level: store.0.unit_level().unwrap_or(0),
|
||
power_type,
|
||
power: store.0.unit_shown_power(power_type).unwrap_or(0),
|
||
max_power: store.0.unit_shown_max_power(power_type).unwrap_or(0),
|
||
// `UnitIsDead 0x517ac0` — health ≤ 0 **or** the dead-looking flag, so a feigning unit is
|
||
// dead to the API, to the target frame's DEAD text and to the greyed portrait alike.
|
||
dead: store.0.unit_reads_dead(),
|
||
// The released-ghost predicate (decision 0308 §1): PLAYER_FLAGS bit 0x10 — a ghost's
|
||
// health is 1, so `dead` above is false for it. Zero/absent on creatures.
|
||
ghost: store.0.player_is_ghost(),
|
||
// `UnitIsCharmed 0x516cf0` — `UNIT_FIELD_CHARMEDBY != 0`, the same field `ui_aura`'s
|
||
// charmed-unit buff leg already reads, so the two cannot disagree about who is charmed.
|
||
charmed: store.0.unit_charmed_by().is_some(),
|
||
// The tapped pair — `UNIT_DYNAMIC_FLAGS` bits 0x4/0x8. Set here and only here, so the
|
||
// object-presence conjunct both predicates carry comes for free: a unit with no live
|
||
// descriptor never reaches this function and reads `false` from `Default`.
|
||
tapped: store.0.unit_tapped(),
|
||
tapped_by_player: store.0.unit_tapped_by_player(),
|
||
// `UnitIsPartyLeader`'s descriptor leg. Zero on a creature, which is what the reference's
|
||
// TYPEMASK_PLAYER restriction achieves there: a non-player has no PLAYER block at all, so
|
||
// the field simply reads absent.
|
||
group_leader: store.0.player_is_group_leader(),
|
||
reaction,
|
||
race: race.map(|(n, _)| n.to_string()),
|
||
race_file: race.map(|(_, f)| f.to_string()),
|
||
class: class.map(|(n, _)| n.to_string()),
|
||
class_file: class.map(|(_, f)| f.to_string()),
|
||
// `UnitHasRelicSlot 0x519e50` — the class byte against `ChrClasses.dbc` field 16, under
|
||
// the reference's own gate and in its order: TYPEMASK_PLAYER first (`0x519e8d`,
|
||
// `[[obj+8]+8] >> 4 & 1` — which is exactly what [`ObjectType::Player`] decodes), the
|
||
// class byte second. The player test is not decoration: a creature carries a class byte
|
||
// too and indexes the same table, so without it a class-2 humanoid NPC answers 1.
|
||
has_relic_slot: matches!(store.0.object_type(), Some(ObjectType::Player))
|
||
&& class_id.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c)))),
|
||
// The descriptor's gender byte (0 male, 1 female) on the API's `UnitSex` scale (2 male,
|
||
// 3 female; 0 = unknown → the binding's nil).
|
||
sex: match store.0.unit_gender() {
|
||
Some(0) => 2,
|
||
Some(1) => 3,
|
||
_ => 0,
|
||
},
|
||
// The tooltip flag lines (decision 0276's unit law): PvP + Skinnable straight off
|
||
// UNIT_FIELD_FLAGS (vmangos UnitDefines.h: 0x1000 / 0x04000000, VERIFIED).
|
||
pvp: store.0.unit_flags() & 0x1000 != 0,
|
||
skinnable: store.0.unit_flags() & 0x0400_0000 != 0,
|
||
// `UnitPlayerControlled` — the same word, bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, which is
|
||
// behaviourally "player-controlled"; `target::relations` already carved it for the duel
|
||
// leg of the selection ring). Wider than `is_player` above: a pet or a charmed creature
|
||
// sets it without being a player.
|
||
player_controlled: store.0.unit_flags() & 0x8 != 0,
|
||
// `UnitAffectingCombat 0x517e10` — the SAME `UNIT_FIELD_FLAGS` word, bit 19
|
||
// (`shr ecx,0x13; test cl,1`). One flag for every token: wow-re's whole-image census of
|
||
// that idiom found the local-player readers reading this identical bit, so there is no
|
||
// player-specific combat latch to model beside it.
|
||
in_combat: store.0.unit_flags() & crate::player::UNIT_FLAG_IN_COMBAT != 0,
|
||
// Free-for-all PvP (decision 0646 §1): `PLAYER_FLAGS` bit 7, the same field the ghost
|
||
// predicate above reads (vmangos `Player.h:322` `PLAYER_FLAGS_FFA_PVP`, cross-read against
|
||
// 0633's byte-level `[+0xe68]+8` bit-7). Zero on creatures, which have no PLAYER_FLAGS —
|
||
// and `UnitIsPVPFreeForAll` is false for them in the reference too.
|
||
is_pvp_ffa: store.0.player_flags() & 0x80 != 0,
|
||
// The unit's CURRENT honor rank (decision 1512): `PLAYER_BYTES_3` byte 3, on the internal
|
||
// 0..=18 scale. It sits in `snapshot` — not in the caller's guid-keyed enrichment like
|
||
// `faction_group` — because it needs nothing but the descriptor, and because it is PUBLIC:
|
||
// the server streams it for every player in view, which is the only reason the inspect
|
||
// pane's `UnitPVPRank("target")` can answer at all. A creature has no PLAYER block and
|
||
// reads 0, the reference's own answer for one.
|
||
pvp_rank: store.0.player_pvp_rank().unwrap_or(0),
|
||
// `PLAYER_BYTES_3` byte 2 — the city-protector title, the same PUBLIC dword as the rank
|
||
// byte above. `UnitPVPName` appends a `PVP_MEDAL<n>` line for a non-zero one; 0 is "no
|
||
// medal", which is every character on this server (vmangos never writes the byte).
|
||
pvp_medal: store.0.player_pvp_medal().unwrap_or(0),
|
||
// is_player + the creature-record extras (subtitle/type/rank/civilian) are the caller's
|
||
// guid-keyed enrichment — [`enrich_unit`].
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Fill a snapshot's guid-keyed tooltip fields (decision 0276's unit law): players flag
|
||
/// `is_player` (the "Race Class (Player)" level line); creatures pull subtitle/type/rank/
|
||
/// civilian/leader from the ask-once template record, and resolve the faction-name line.
|
||
/// The type word is `CreatureType.dbc`'s enUS display list (ids 1..9 — a fixed 1.12
|
||
/// vocabulary; 10 "Not specified" shows nothing).
|
||
pub(crate) fn enrich_unit(
|
||
state: &mut UnitState,
|
||
guid: u64,
|
||
names: &NameCache,
|
||
store: &ObjectStore,
|
||
factions: Option<&Factions>,
|
||
self_store: Option<&ObjectStore>,
|
||
) {
|
||
if benilla_protocol::guid::is_player(guid) {
|
||
state.is_player = true;
|
||
// No faction line for players — their PLAYER,* factions carry no reputation slot, so
|
||
// the builder's rep-index gate always drops the line (byte-identical to resolving it).
|
||
return;
|
||
}
|
||
let Some(entry) = benilla_protocol::guid::entry(guid) else {
|
||
return;
|
||
};
|
||
if let Some(rec) = names.creature_record(entry) {
|
||
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`
|
||
// directly: an enslaved elite reads rank 0, so it loses its border dragon, its ELITE
|
||
// tooltip word and its world-boss skull together, exactly as in the reference.
|
||
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()
|
||
})();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drain the `TogglePVP` intents into `CMSG_TOGGLE_PVP` (decision 0646 §3) — `/pvp` is the only
|
||
/// caller the reference has (`SlashCmdList["PVP"]`), and the only one we have.
|
||
///
|
||
/// This rides the unit feed's plugin rather than a `ui_pvp` of its own: the family's whole client
|
||
/// state is one remembered bit ([`UnitFeedMemo::pvp_desired`]), which lives with the feed's other
|
||
/// self-flag edge (`in_combat`) rather than in a plugin of its own.
|
||
fn drain_pvp_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for _ in 0..script.take_pvp_toggles() {
|
||
let _ = commands.0.send(crate::net::ClientCommand::TogglePvp);
|
||
}
|
||
}
|
||
|
||
/// Drain the `ShowHelm`/`ShowCloak` flips into `CMSG_TOGGLE_HELM`/`CMSG_TOGGLE_CLOAK` (decision
|
||
/// 1472) — the Options window's two equipment-display rows, and the only callers there are.
|
||
///
|
||
/// The VM has already decided *whether* a flip is needed (the setter compares the asked-for state
|
||
/// against the belief it holds and queues nothing when they agree), because only the VM knows what
|
||
/// the row just did optimistically. This end is the pure send, the PvP drain's shape exactly.
|
||
fn drain_worn_display_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for which in script.take_worn_display_toggles() {
|
||
let _ = commands.0.send(match which {
|
||
WornDisplay::Helm => crate::net::ClientCommand::ToggleHelm,
|
||
WornDisplay::Cloak => crate::net::ClientCommand::ToggleCloak,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Drain the `SetActionBarToggles` posts into `CMSG_SET_ACTIONBAR_TOGGLES` (wow-re
|
||
/// `system/ui/scratch/action-bar-toggles.md` §3) — the Options window's four extra-bar rows, and
|
||
/// the only callers there are.
|
||
///
|
||
/// **Every queued call becomes a packet**, with no did-it-change gate and no coalescing: the real
|
||
/// binding has neither (unlike `ShowHelm`/`ShowCloak`, which send only on a difference), so two
|
||
/// calls in a frame are two sends. The byte is absolute rather than a flip, so a duplicate is
|
||
/// harmless — but dropping one would be an optimisation the reference does not make, and the
|
||
/// server is the only store this preference has.
|
||
fn drain_action_bar_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for toggles in script.take_action_bar_toggle_sends() {
|
||
let _ = commands
|
||
.0
|
||
.send(crate::net::ClientCommand::SetActionBarToggles { toggles });
|
||
}
|
||
}
|
||
|
||
/// `PLAYER_FLAGS_PVP_DESIRED` — the PvP *preference* bit (vmangos `PlayerDefines.h`), which is what
|
||
/// `CMSG_TOGGLE_PVP` flips. Not to be confused with `UNIT_FIELD_FLAGS`' `PVP` bit `0x1000`, the flag
|
||
/// the icon draws: the preference clears instantly, the flag lingers for the server's timer.
|
||
const PLAYER_FLAGS_PVP_DESIRED: u32 = 0x200;
|
||
|
||
/// `PLAYER_FLAGS_RESTING` — inside a rest area now (vmangos `Player.h:320`); the bit
|
||
/// `IsResting 0x516ea0` tests (`shr 5; test 1` — wow-re rested-xp-bindings.md §3).
|
||
const PLAYER_FLAGS_RESTING: u32 = 0x20;
|
||
|
||
/// `PLAYER_FLAGS` bit 12 / bit 13 — the two **play-time** regimes an anti-addiction realm puts an
|
||
/// account into, read by `PartialPlayTime` (`0x48eb70`) and `NoPlayTime` (`0x48ebe0`). Decision
|
||
/// 1746 carved both, and settled that `0x1000` is PARTIAL_PLAY_TIME on 5875 rather than the
|
||
/// pre-1.6.1 `CAN_SELF_RESURRECT` it had been read as. Stock `PlayerFrame_UpdatePlaytime` tests
|
||
/// them at LOAD, so their absence is a raise on the first frame, not a cosmetic gap.
|
||
const PLAYER_FLAGS_PARTIAL_PLAY_TIME: u32 = 0x1000;
|
||
const PLAYER_FLAGS_NO_PLAY_TIME: u32 = 0x2000;
|
||
|
||
/// The PvP-preference announcement rule (decision 0652): `(toast, verbose)` on a real change of the
|
||
/// bit, `None` otherwise.
|
||
///
|
||
/// `was: None` is first sight and stays silent — the reference's handler is driven by a
|
||
/// *changed-bits* mask (`new ^ old`), so the descriptor that first carries the flag at login says
|
||
/// nothing. The two texts are verbatim 1.12 `GlobalStrings.lua`: the `ERR_PVP_TOGGLE_*` toast
|
||
/// (l.1788-1789) and the `PVP_TOGGLE_*_VERBOSE` chat sentence (l.3221-3222). Both are
|
||
/// argument-free, so there is no format step.
|
||
fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'static str)> {
|
||
if was? == now {
|
||
return None;
|
||
}
|
||
Some(if now {
|
||
(
|
||
"PvP combat toggled on",
|
||
"You are now flagged for PvP combat and will remain so until toggled off.",
|
||
)
|
||
} else {
|
||
(
|
||
"PvP combat toggled off",
|
||
"You will be unflagged for PvP combat after five minutes of non-PvP action in friendly territory.",
|
||
)
|
||
})
|
||
}
|
||
|
||
/// The rest-state chat line (decision 1098; wow-re rested-xp-bindings.md §§6-10, byte-verified
|
||
/// §5): the rest-state BYTE watcher `0x5de4e0` messages only on a real old≠new transition (the
|
||
/// dispatcher's `rep cmpsb` mirror diff at `0x4655bb`), through the hard-coded 3×2 pair table
|
||
/// `0x80af50` — state 1 → `ERR_EXHAUSTION_RESTED`, state 2 → `ERR_EXHAUSTION_NORMAL`, state 0 →
|
||
/// the table's deliberate no-message sentinel (id 0x1d1), states ≥ 3 gated off before the table
|
||
/// (`cmp esi,3; jae`), so the beta tiers never speak even though their strings ship. The line is
|
||
/// a plain yellow SYSTEM chat message (`CHAT_MSG_SYSTEM`) — never UIErrorsFrame, no sound.
|
||
/// enUS literals like every app-side chat line (`level_up_lines`' shape); the keys above are the
|
||
/// GlobalStrings homes. Entering rested also arms a one-shot tutorial popup (id 0x19) — the
|
||
/// tutorial system isn't built, a named cut.
|
||
fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> {
|
||
if prev == new {
|
||
return None;
|
||
}
|
||
match new {
|
||
1 => Some("You feel rested."),
|
||
2 => Some("You feel normal."),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// A unit's PvP faction group — `UnitFactionGroup`'s pair, as the icon law reads it (decision
|
||
/// 0646 §1/§3): `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s group mask → the
|
||
/// `FactionGroup.dbc` name of its **side** bit.
|
||
///
|
||
/// The `& 6` is the whole rule and it is load-bearing, not a tidy-up: every playable race's
|
||
/// template carries `Player|<side>` (mask 3 Alliance, 5 Horde) and so do the PvP-flagged city
|
||
/// guards, while `FactionGroup.dbc`'s Player and Monster rows have EMPTY localized names and no
|
||
/// `UI-PVP-Player`/`UI-PVP-Monster` texture ships. Masking to the two side bits before the lookup
|
||
/// is therefore the only reading the shipped art admits — a lowest-bit walk would answer "Player"
|
||
/// for every player in the game.
|
||
pub(crate) fn faction_group(store: &ObjectStore, factions: Option<&Factions>) -> Option<String> {
|
||
let catalog = factions?.catalog();
|
||
let template = catalog.template(store.0.unit_faction_template()?)?;
|
||
catalog
|
||
.faction_group_name(template.group_mask & 6)
|
||
.map(str::to_string)
|
||
}
|
||
|
||
/// `CreatureType.dbc` id → the enUS display word (the level line's class slot for creatures).
|
||
fn creature_type_word(t: u32) -> Option<&'static str> {
|
||
Some(match t {
|
||
1 => "Beast",
|
||
2 => "Dragonkin",
|
||
3 => "Demon",
|
||
4 => "Elemental",
|
||
5 => "Giant",
|
||
6 => "Undead",
|
||
7 => "Humanoid",
|
||
8 => "Critter",
|
||
9 => "Mechanical",
|
||
// The shipped `CreatureType.dbc` is **1..11 dense**, not 1..9 — this table stopped two
|
||
// rows early. 11 is reachable and wow-re's own nameplate filter tests for it
|
||
// (`0x605570 == 0xb`).
|
||
11 => "Totem",
|
||
// 10 is "Not specified" in the DBC. Deliberately still None: this word is the tooltip's
|
||
// level-line class slot and a literal "Not specified" there is noise. The cost is named
|
||
// rather than hidden — `UnitCreatureType` shares this field, so it answers nil for a
|
||
// type-10 unit where the reference answers the DBC word.
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Diff a token's fresh snapshot against the last one pushed and fire the per-field Era events.
|
||
/// `prev = None` (the token just appeared) treats every present field as a transition.
|
||
pub(crate) fn fire_transitions(
|
||
script: &mut UiScript,
|
||
token: &str,
|
||
prev: Option<&UnitState>,
|
||
cur: &UnitState,
|
||
) {
|
||
let tok = || ScriptValue::Str(token.to_string());
|
||
let changed = |f: fn(&UnitState) -> u64| prev.is_none_or(|p| f(p) != f(cur));
|
||
|
||
if changed(|u| u64::from(u.health)) {
|
||
script.fire_event("UNIT_HEALTH", vec![tok()]);
|
||
}
|
||
if changed(|u| u64::from(u.max_health)) {
|
||
script.fire_event("UNIT_MAXHEALTH", vec![tok()]);
|
||
}
|
||
if changed(|u| u64::from(u.level)) {
|
||
script.fire_event("UNIT_LEVEL", vec![tok()]);
|
||
}
|
||
if changed(|u| u64::from(u.power_type)) {
|
||
script.fire_event("UNIT_DISPLAYPOWER", vec![tok()]);
|
||
}
|
||
// The POWER pair is named per resource in 1.12, not once with the token as arg2: the reference's
|
||
// `UnitFrameManaBar_Initialize` registers `UNIT_MANA`/`UNIT_RAGE`/`UNIT_FOCUS`/`UNIT_ENERGY`/
|
||
// `UNIT_HAPPINESS` and the five `UNIT_MAX*` twins (`UnitFrame.lua:190-199`), and nothing in
|
||
// 1.12 FrameXML has ever heard of `UNIT_POWER_UPDATE`. `power_token` already yields exactly the
|
||
// suffix, so the name is the token. Health is unaffected — `UNIT_HEALTH`/`UNIT_MAXHEALTH` are
|
||
// spelled the same in both eras — and so is `UNIT_DISPLAYPOWER` above.
|
||
//
|
||
// This was live breakage, not tidiness: while we fired only the Era names, the stock unit frames
|
||
// registered only the 1.12 ones, so a mana/rage/energy bar never moved except when something
|
||
// else happened to call `UnitFrame_Update` (a target change, a pet summon, a frame show).
|
||
// Decision 1819.
|
||
if changed(|u| u64::from(u.power)) {
|
||
script.fire_event(
|
||
&format!("UNIT_{}", power_token(cur.power_type)),
|
||
vec![tok()],
|
||
);
|
||
}
|
||
if changed(|u| u64::from(u.max_power)) {
|
||
script.fire_event(
|
||
&format!("UNIT_MAX{}", power_token(cur.power_type)),
|
||
vec![tok()],
|
||
);
|
||
}
|
||
if prev.is_none_or(|p| p.name != cur.name) {
|
||
script.fire_event("UNIT_NAME_UPDATE", vec![tok()]);
|
||
}
|
||
// UNIT_CLASSIFICATION_CHANGED (decision 0782) — the target frame's border-art repaint wire, and
|
||
// the only frame that registers it in the reference. Edge-fired on the gated rank, which IS the
|
||
// classification (`classification_word` is a pure table index), so this fires exactly when the
|
||
// border would change: the creature query landing on a freshly-seen elite, and a mob being
|
||
// enslaved or released. Without it the border would only be right on re-target, because the
|
||
// ref's own `TargetFrame_Update` is the sole other caller of CheckClassification.
|
||
if prev.is_none_or(|p| p.rank != cur.rank) {
|
||
script.fire_event("UNIT_CLASSIFICATION_CHANGED", vec![tok()]);
|
||
}
|
||
// UNIT_FACTION (decision 0646 §2) — the PvP-icon repaint wire, fired on the three fields the
|
||
// icon law reads. Exactly the three frames that draw the icon register it in the reference
|
||
// (player, target, party member) and nothing else does. Edge-fired, so the player frame's
|
||
// `igPVPUpdate` sounds once per flag change rather than once per repaint.
|
||
//
|
||
// **And on the TAPPED bit, which is not a faction field and fires this event anyway.** The
|
||
// reference's `UNIT_DYNAMIC_FLAGS` watcher tests bit `0x4` and dispatches event id **29** —
|
||
// `UNIT_FACTION` — at `0x6005a1 test al,4` -> `0x6005b0 mov edx,0x1d` -> `0x515e50` (wow-re
|
||
// `ui/scratch/tapped-bits-and-unit-faction.md`, controlled across all 37 fire sites).
|
||
//
|
||
// This line is the whole reason `UnitIsTapped` is worth publishing. Without it both verbs
|
||
// answer CORRECTLY and no frame ever repaints: pfUI's grey-bar branch runs inside
|
||
// `RefreshUnit`, which is event-driven, so a mob that becomes someone else's tap would stay
|
||
// full-colour until something unrelated happened to refresh it. A passing unit test on the
|
||
// predicates would have said nothing about that — the mechanism existing is not the mechanism
|
||
// applying (`method.md` step 5).
|
||
//
|
||
// Bit `0x8` deliberately has NO arm here: the reference's watcher has none for it either
|
||
// (proven by enumerating all 122 instructions and 12 branches of `0x600440`). The pair is read
|
||
// together, and the `0x4` edge is what moves.
|
||
if prev.is_none_or(|p| {
|
||
(p.pvp, p.is_pvp_ffa, &p.faction_group, p.tapped)
|
||
!= (cur.pvp, cur.is_pvp_ffa, &cur.faction_group, cur.tapped)
|
||
}) {
|
||
script.fire_event("UNIT_FACTION", vec![tok()]);
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn feed_units(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
// `ChrClasses.dbc` field 16, the only thing `UnitHasRelicSlot` reads. Absent when the client
|
||
// data failed to load, in which case no class reads as having a relic slot — the reference's
|
||
// own bounds-check-fails leg.
|
||
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
||
|
||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||
selection: Res<Selection>,
|
||
// The guid -> entity map the `"targettarget"` hop lands in, the same index `/assist` resolves
|
||
// its basis' `UNIT_FIELD_TARGET` through (`target::by_name`). `Option` because it belongs to
|
||
// `NetPlugin` (net.rs's `init_resource`) and this feed does not: a UI-only harness runs this
|
||
// system with no net stack at all, and a bare `Res` turns that into a system-validation panic
|
||
// rather than a token that names nobody. Same shape as `factions` below, same reason.
|
||
index: Option<Res<crate::net::GuidIndex>>,
|
||
mut stores: UnitStores,
|
||
mut feed: ResMut<UnitFeedState>,
|
||
mut names: ResMut<NameCache>,
|
||
commands: Res<NetCommands>,
|
||
factions: Option<Res<Factions>>,
|
||
reputations: Res<Reputations>,
|
||
group: Res<crate::ui_party::GroupState>,
|
||
mut chat: ResMut<ChatLog>,
|
||
// The guild-identity cache `GetGuildInfo(unit)` reads — `ResMut` because it is a LAZY cache
|
||
// (decision 1257): a lookup that misses is what sends the `CMSG_GUILD_QUERY`, exactly as a
|
||
// `NameCache::resolve` miss sends the name query above.
|
||
mut guild: ResMut<crate::ui_guild::GuildState>,
|
||
// The NPC the player is interacting with, for the `"npc"` token below. `Option` for the same
|
||
// reason `index` and `factions` are: a UI-only harness runs this feed with no session plugin.
|
||
interact: Option<Res<crate::ui_session::InteractNpc>>,
|
||
// The account's rested billing minutes ride the world-enter message, which is the only moment
|
||
// they ever arrive (decision 1820): the value comes off `SMSG_AUTH_RESPONSE` and the reference
|
||
// parks it in a process-lifetime global. `MessageReader` rather than a resource because it is
|
||
// an EDGE, and it is read here rather than in a system of its own because this feed already
|
||
// holds the script.
|
||
// `Option` for the same reason `index`, `factions` and `interact` above are: the message is
|
||
// registered by `NetPlugin`, and a UI-only harness runs this feed with no net stack at all —
|
||
// a bare reader turns that into a system-validation panic.
|
||
entered_world: Option<MessageReader<crate::net::EnteredWorldMessage>>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
// Ahead of the gate below, which is about descriptor deltas: this is a login edge and has no
|
||
// snapshot to diff.
|
||
if let Some(entered) =
|
||
entered_world.and_then(|mut r| r.read().last().map(|m| m.billing_time_rested))
|
||
{
|
||
script.set_billing_time_rested(entered);
|
||
}
|
||
let chr = classes.as_deref().map(|t| &t.0);
|
||
// One reborrow so the memo (`feed.vm`) and `feed.warned_sideless` can be borrowed as the
|
||
// disjoint fields they are — through the `ResMut` deref they would alias.
|
||
let feed = &mut *feed;
|
||
let (memo, vm_reset) = feed.vm.get_reset(&script);
|
||
|
||
// The gate (1439): every input the two snapshots and the edge diffs below read — any
|
||
// descriptor change or DESPAWN (a removed store is invisible to `Changed`), the selection,
|
||
// the group/reputation/faction state, and the two lazy caches by their landed counters.
|
||
let names_moved = memo.names_generation.moved(names.generation());
|
||
let guild_moved = memo.guild_generation.moved(guild.identity_generation());
|
||
let selection_changed = selection.is_changed();
|
||
let stores_changed = !stores.changed.is_empty();
|
||
let stores_removed = !stores.removed.is_empty();
|
||
let group_changed = group.is_changed();
|
||
let reps_changed = reputations.is_changed();
|
||
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
|
||
gate::trace(
|
||
"feed_units",
|
||
&[
|
||
("vm_reset", vm_reset),
|
||
("names", names_moved),
|
||
("guild", guild_moved),
|
||
("selection", selection_changed),
|
||
("stores", stores_changed),
|
||
("removed", stores_removed),
|
||
("group", group_changed),
|
||
("reputations", reps_changed),
|
||
("factions", factions_changed),
|
||
],
|
||
);
|
||
let gate = gate::Gate::new(
|
||
vm_reset
|
||
|| names_moved
|
||
|| guild_moved
|
||
|| selection_changed
|
||
|| stores_changed
|
||
|| stores_removed
|
||
|| group_changed
|
||
|| reps_changed
|
||
|| factions_changed,
|
||
);
|
||
stores.removed.clear();
|
||
if gate.skip() {
|
||
return;
|
||
}
|
||
|
||
// "player" = our own avatar's descriptor; "target" = the selected entity's. Absent → None, which
|
||
// set_unit clears (UnitExists false), exactly as the real client reports a missing unit. Names
|
||
// resolve through the cache — a miss queries the server once and lands on a later frame.
|
||
let self_pair = self_q.iter().next();
|
||
let player = self_pair.map(|(store, guid)| {
|
||
let name = names.resolve(guid.0, &commands).map(str::to_string);
|
||
let mut s = snapshot(store, name, 0, chr);
|
||
s.is_player = true;
|
||
// The stated `is_connected` gap, closed for every token this feed pushes — the field's own
|
||
// doc names the feed as what must set it ("mirroring `exists`"), and a token we push at all
|
||
// is one whose descriptor is streamed. It was reachable, not academic: `GREY_ROW["WHISPER"]`
|
||
// (UnitPopup.xml) greys on `not UnitIsConnected(unit)`, so Whisper sat greyed in the
|
||
// right-click menu of every player you targeted. What stays out of reach is real link-death
|
||
// — the only wire that carries it is the group roster's status byte, which the party feed
|
||
// already reads for its own tokens; a non-group player logging out lingers here as
|
||
// connected until that lands.
|
||
s.is_connected = true;
|
||
// Identity + the raid-target board mark (decision 0434 §5's popup gating; §6's board).
|
||
s.guid = guid.0;
|
||
s.raid_target = group.raid_target_index(guid.0);
|
||
s.faction_group = faction_group(store, factions.as_deref());
|
||
// `GetGuildInfo("player")` — the unit's own PUBLIC descriptor fields (191/192) joined
|
||
// against the app's guild-identity cache, which the miss also asks for (decision 1257).
|
||
// Filled here rather than in `snapshot` for the reason `faction_group` and `can_attack`
|
||
// are: it needs a resource, and `snapshot`'s other six call sites hold none.
|
||
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
|
||
s
|
||
});
|
||
// The GM-mode confound, made audible (decision 0657). A self player whose faction template
|
||
// names no side is almost always GM mode: vmangos swaps a GM to faction template 35, whose
|
||
// `FactionTemplate.dbc` group mask is 0, and every faction-derived surface then has no side to
|
||
// draw — the PvP flag icon most visibly, which simply hides. That is faithful (the only side
|
||
// art that ships is Alliance/Horde/FFA), and it is indistinguishable on screen from the icon
|
||
// being broken, which has now cost two separate sessions an investigation. So it is a BENCH
|
||
// diagnostic, not UI: nothing appears on screen, exactly as in the reference.
|
||
//
|
||
// The honor arc (1512) put a SECOND surface behind this same side: a rank's title is the
|
||
// GlobalString `PVP_RANK_<rank>_<team>`, and with no side there is no team digit, so
|
||
// `GetPVPRankInfo` answers nil and the Honor tab renders `NONE` at every rank — for a Grand
|
||
// Marshal. That reads exactly like an unbuilt pane, which is why it is named in the warning
|
||
// rather than left for the next investigation to rediscover.
|
||
if let Some(p) = &player {
|
||
let sideless = p.faction_group.is_none();
|
||
if sideless && !feed.warned_sideless {
|
||
warn!(
|
||
"faction: our own template names no side (usually GM mode — vmangos forces \
|
||
template 35, group mask 0). Faction-derived UI cannot resolve a side while this \
|
||
holds: the PvP flag icon stays hidden however flagged you are, and the Honor tab's rank \
|
||
title reads NONE at every rank (PVP_RANK_<rank>_<team> has no team digit). \
|
||
`.gm off` restores both."
|
||
);
|
||
}
|
||
feed.warned_sideless = sideless;
|
||
}
|
||
// (The VM-half memo was taken at the top — the gate needs its reset flag. `warned_sideless`
|
||
// stays server memory outside it, which the disjoint field borrows above preserve.)
|
||
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
|
||
let store = stores.all.get(e).ok()?;
|
||
let name = names.resolve(guid, &commands).map(str::to_string);
|
||
// The target's reaction toward us, on the `UnitReaction` 1..8 scale — the same decode the
|
||
// selection ring runs (reputation-first, else the faction-template comparator). `ring_reaction`
|
||
// returns the raw 0..7 rank (neutral its no-data fallback), which is `UnitReaction − 1`; +1
|
||
// lands it on the Lua scale the name-plate palette (`UnitReactionColor`) indexes.
|
||
let reaction = ring_reaction(
|
||
factions.as_deref(),
|
||
&reputations,
|
||
Some(store),
|
||
self_pair.map(|(s, _)| s),
|
||
) + 1;
|
||
let mut s = snapshot(store, name, reaction, chr);
|
||
s.guid = guid;
|
||
s.is_connected = true; // see the player leg
|
||
s.raid_target = group.raid_target_index(guid);
|
||
s.faction_group = faction_group(store, factions.as_deref());
|
||
// The byte-confirmed CanAttack 0x606980 (decision 0172) — the same predicate TAB and the
|
||
// combat flash run; `UnitCanAttack("player","target")` gates the target frame's
|
||
// difficulty-colored level (ref TargetFrame_CheckLevel).
|
||
s.can_attack = crate::target::can_attack(
|
||
Some(store),
|
||
factions.as_deref(),
|
||
&reputations,
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
// `GetGuildInfo("target")` — see the player leg. PLAYER_GUILDID/RANK are PUBLIC, which is
|
||
// the whole reason the binding is per-unit rather than per-player.
|
||
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
|
||
enrich_unit(
|
||
&mut s,
|
||
guid,
|
||
&names,
|
||
store,
|
||
factions.as_deref(),
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
Some(s)
|
||
});
|
||
|
||
// `"targettarget"` — the target's own target, one hop off its `UNIT_FIELD_TARGET`. It is the
|
||
// same read `/assist` runs (`target::by_name`), and the same shape 1.12's own token resolver
|
||
// walks for the `target` SUFFIX: resolve the base unit, then follow the guid its target field
|
||
// carries. Only a STREAMED guid resolves — an unstreamed one leaves `UnitExists("targettarget")`
|
||
// false, exactly as an out-of-range party member is false.
|
||
//
|
||
// No `guild` leg, deliberately: `unit_guild` is the LAZY cache whose miss SENDS a
|
||
// `CMSG_GUILD_QUERY` (1257), and nothing asks a target-of-target for its guild — the party and
|
||
// raid tokens leave it unfilled for the same reason. Everything else here is a pure read of a
|
||
// descriptor we already hold.
|
||
let tot = selection
|
||
.target
|
||
.and_then(|e| stores.all.get(e).ok())
|
||
.and_then(|s| s.0.unit_target())
|
||
.filter(|guid| *guid != 0)
|
||
.and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid)))
|
||
.and_then(|(entity, guid)| {
|
||
let store = stores.all.get(entity).ok()?;
|
||
let name = names.resolve(guid, &commands).map(str::to_string);
|
||
let reaction = ring_reaction(
|
||
factions.as_deref(),
|
||
&reputations,
|
||
Some(store),
|
||
self_pair.map(|(s, _)| s),
|
||
) + 1;
|
||
let mut s = snapshot(store, name, reaction, chr);
|
||
s.guid = guid;
|
||
s.is_connected = true; // see the player leg
|
||
s.raid_target = group.raid_target_index(guid);
|
||
s.faction_group = faction_group(store, factions.as_deref());
|
||
s.can_attack = crate::target::can_attack(
|
||
Some(store),
|
||
factions.as_deref(),
|
||
&reputations,
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
enrich_unit(
|
||
&mut s,
|
||
guid,
|
||
&names,
|
||
store,
|
||
factions.as_deref(),
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
Some(s)
|
||
});
|
||
|
||
// `"player"` is pushed only while the descriptor EXISTS: its absence is "no data source",
|
||
// never "the player stopped existing". The two absent windows are pre-arrival at login —
|
||
// where a `None` push would erase the roster seat (`seat_from_roster`) that addon file scopes
|
||
// and the loading-screen UI read — and the logout despawn frames, where `PLAYER_LOGOUT`
|
||
// handlers still key their saved state by `UnitName("player")` (the reference keeps the unit
|
||
// valid through its shutdown; a fresh VM starts with no `"player"` anyway, so nothing needs
|
||
// the clear). `"target"` keeps the unconditional push: a selection's absence IS data — the
|
||
// deselect/despawn transition the real client also reports.
|
||
// Both pushes diff against the SAME memo the event loop below uses (1439): an identical
|
||
// snapshot re-pushed is invisible to the VM, so only a real change pays the clone.
|
||
if let Some(cur) = &player {
|
||
if memo.last.get("player") != Some(cur) {
|
||
gate.audit("feed_units", "the player snapshot");
|
||
script.set_unit("player", player.clone());
|
||
}
|
||
}
|
||
let target_dirty = match (&target, memo.last.get("target")) {
|
||
(Some(cur), Some(prev)) => cur != prev,
|
||
(None, None) => false,
|
||
_ => true,
|
||
};
|
||
if target_dirty {
|
||
gate.audit("feed_units", "the target snapshot");
|
||
script.set_unit("target", target.clone());
|
||
}
|
||
// The same "absence IS data" push as the target's above: the target dropping ITS target is a
|
||
// transition the frame has to hear about, and clearing the token is how it hears it.
|
||
let tot_dirty = match (&tot, memo.last.get("targettarget")) {
|
||
(Some(cur), Some(prev)) => cur != prev,
|
||
(None, None) => false,
|
||
_ => true,
|
||
};
|
||
if tot_dirty {
|
||
gate.audit("feed_units", "the target-of-target snapshot");
|
||
script.set_unit("targettarget", tot.clone());
|
||
}
|
||
|
||
// `"npc"` — the unit the player is interacting with, resolved through the SAME interaction guid
|
||
// the real client's token reads (`CGGameUI`'s `[0xb4e2d0]`, what `CGGameUI::SetInteractNPC`
|
||
// writes; wow-re confirms the guild registrar's own opener is one of that function's fourteen
|
||
// callers). `crate::ui_session::InteractNpc` is benilla's model of exactly that cell, so the
|
||
// token and the portrait booth cannot disagree about who "npc" is.
|
||
//
|
||
// **It had no feed at all until decision 1678**, and the gap was invisible because nothing
|
||
// called it: `TaxiFrame.xml` and `TradeFrame.xml` both take the NPC's name from an event
|
||
// argument instead and say in their own comments that they are deviating from the reference's
|
||
// `UnitName("NPC")` to do it. `GuildRegistrarFrame.xml` is the first window here to ship the
|
||
// reference's line unchanged — and it painted a blank name banner, because
|
||
// `GUILD_REGISTRAR_SHOW` is verified to carry no arguments, so there is nothing else for it to
|
||
// read. The probe caught it; a one-frame-lag theory was the first (wrong) explanation.
|
||
//
|
||
// No `guild` leg, for `"targettarget"`'s reason: nothing asks an NPC for its guild, and the
|
||
// lookup is the lazy cache whose miss SENDS a query.
|
||
let npc = interact
|
||
.as_deref()
|
||
.and_then(|i| Some((i.0?, i.1?)))
|
||
.and_then(|(entity, guid)| {
|
||
let store = stores.all.get(entity).ok()?;
|
||
let name = names.resolve(guid, &commands).map(str::to_string);
|
||
let reaction = ring_reaction(
|
||
factions.as_deref(),
|
||
&reputations,
|
||
Some(store),
|
||
self_pair.map(|(s, _)| s),
|
||
) + 1;
|
||
let mut s = snapshot(store, name, reaction, chr);
|
||
s.guid = guid;
|
||
s.is_connected = true;
|
||
s.raid_target = group.raid_target_index(guid);
|
||
s.faction_group = faction_group(store, factions.as_deref());
|
||
s.can_attack = crate::target::can_attack(
|
||
Some(store),
|
||
factions.as_deref(),
|
||
&reputations,
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
enrich_unit(
|
||
&mut s,
|
||
guid,
|
||
&names,
|
||
store,
|
||
factions.as_deref(),
|
||
self_pair.map(|(s, _)| s),
|
||
);
|
||
Some(s)
|
||
});
|
||
// "Absence IS data" again: closing an NPC window must clear the token, or the next window's
|
||
// first frame paints the last NPC's name.
|
||
let npc_dirty = match (&npc, memo.last.get("npc")) {
|
||
(Some(cur), Some(prev)) => cur != prev,
|
||
(None, None) => false,
|
||
_ => true,
|
||
};
|
||
if npc_dirty {
|
||
gate.audit("feed_units", "the interaction-NPC snapshot");
|
||
script.set_unit("npc", npc.clone());
|
||
}
|
||
|
||
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
|
||
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
|
||
// shape. Absent fields read 0 (a fresh descriptor's zero default; the bar shows empty until XP
|
||
// streams in). BEFORE the PLAYER_ENTERING_WORLD fire below, so the first paint reads real
|
||
// values (1087 — the tick's handler divides by UnitXPMax).
|
||
if let Some((store, _)) = self_q.iter().next() {
|
||
let xp = store.0.player_xp().unwrap_or(0);
|
||
let next = store.0.player_next_level_xp().unwrap_or(0);
|
||
if memo.last_xp != Some((xp, next)) {
|
||
gate.audit("feed_units", "the XP pair");
|
||
memo.last_xp = Some((xp, next));
|
||
script.set_player_xp(xp, next);
|
||
script.fire_event("PLAYER_XP_UPDATE", vec![]);
|
||
}
|
||
}
|
||
|
||
// The rest feed (decisions 1082/1087): the `PLAYER_BYTES_2` rest-state byte, the
|
||
// `PLAYER_REST_STATE_EXPERIENCE` pool and PLAYER_FLAGS, pushed as one snapshot. Two watches,
|
||
// the byte-verified grain (wow-re rested-xp-bindings.md §5): `UPDATE_EXHAUSTION` on a
|
||
// state-or-pool change (the client installs a watcher on each — `0x5de4e0` on the byte,
|
||
// `0x5de4b0` on the pool field), `PLAYER_UPDATE_RESTING` on **any PLAYER_FLAGS delta**
|
||
// (`0x5ee990` fires it beside PLAYER_FLAGS_CHANGED without testing which bit moved — the
|
||
// resting bit is just its loudest consumer). Runs before the PLAYER_ENTERING_WORLD fire
|
||
// below, like the XP push: in the real client the descriptor always lands before that
|
||
// event, so the first paint reads real state — the model's byte-2 default (its doc) is the
|
||
// backstop, this ordering is the guarantee itself.
|
||
if let Some((store, _)) = self_q.iter().next() {
|
||
let rest = (
|
||
store.0.player_rest_state().unwrap_or(0),
|
||
store.0.player_rest_state_experience().unwrap_or(0),
|
||
store.0.player_flags(),
|
||
);
|
||
if memo.last_rest != Some(rest) {
|
||
gate.audit("feed_units", "the rest snapshot");
|
||
let prev = memo.last_rest;
|
||
memo.last_rest = Some(rest);
|
||
script.set_rest_state(rest.0, rest.1, rest.2 & PLAYER_FLAGS_RESTING != 0);
|
||
// The same descriptor word carries the two play-time bits, and they ride the same
|
||
// memo: `0x5ee990` fires PLAYER_FLAGS_CHANGED on ANY PLAYER_FLAGS delta without
|
||
// testing which bit moved, so one snapshot is the faithful granularity for all three.
|
||
script.set_play_time(
|
||
rest.2 & PLAYER_FLAGS_PARTIAL_PLAY_TIME != 0,
|
||
rest.2 & PLAYER_FLAGS_NO_PLAY_TIME != 0,
|
||
);
|
||
if prev.map(|p| (p.0, p.1)) != Some((rest.0, rest.1)) {
|
||
script.fire_event("UPDATE_EXHAUSTION", vec![]);
|
||
}
|
||
// "You feel rested." / "You feel normal." (decision 1098): the BYTE watcher alone
|
||
// messages, and only on a real transition — `prev` None is the login descriptor,
|
||
// which the real client's fresh-CREATE path never runs through the notify pass
|
||
// (byte-verified: login is structurally silent). The pool watcher never messages.
|
||
if let Some(p) = prev {
|
||
if let Some(text) = rest_state_message(p.0, rest.0) {
|
||
chat.push_event(ChatEvent::text_only(
|
||
ChatEventKind::System,
|
||
text.to_string(),
|
||
));
|
||
}
|
||
}
|
||
if prev.map(|p| p.2) != Some(rest.2) {
|
||
script.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The ding feed: `PLAYER_LEVEL_UP` (arg1 = the new level) when our avatar's
|
||
// `UNIT_FIELD_LEVEL` CHANGES — the event the exhaustion tick (1082) and the max-level rail
|
||
// (1094) register; it was a dead registration until 1094. Any change, not only a rise:
|
||
// vmangos `GiveLevel` runs for demotions too (a GM `.character level` down) and sends
|
||
// `SMSG_LEVELUP_INFO` unconditionally, so the real client hears every change — 1094's
|
||
// rise-only guard left the rail latched shown after a 60→1 demote (the 1106 live repro).
|
||
// Trigger PROVISIONAL (0578's pattern): fired off the descriptor diff, which lands in the
|
||
// same update batch as the ding's XP fields, so consumers read a coherent picture. The
|
||
// real client plausibly fires it from its `SMSG_LEVELUP_INFO` handler instead, with the
|
||
// packet's gain tuple as arg2+ — unpinned, and no 1.12 FrameXML consumer reads past arg1
|
||
// (`ReputationWatchBar_Update` takes arg1; the tick's handler takes none), so the extra
|
||
// args wait for a consumer.
|
||
if let Some((store, _)) = self_q.iter().next() {
|
||
if let Some(level) = store.0.unit_level() {
|
||
let prev = memo.last_level.replace(level);
|
||
if prev.is_some_and(|p| level != p) {
|
||
gate.audit("feed_units", "the level edge");
|
||
script.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(i64::from(level))]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The action-bar toggle feed: `PLAYER_FIELD_BYTES` byte 2 — which of the four extra bars the
|
||
// player has switched on (wow-re `system/ui/scratch/action-bar-toggles.md`). PRIVATE, like the
|
||
// combo byte one address down (`+0x1029` vs `+0x102a`), and pushed on the EDGE.
|
||
//
|
||
// **This push is the ONLY thing that moves the VM's copy**, and that is the mechanism rather
|
||
// than our simplification: no instruction in the real client writes this cell (§4.1 — the one
|
||
// `+0x102a` access image-wide is `GetActionBarToggles`' read), so `SetActionBarToggles` posts
|
||
// the byte and leaves the descriptor alone until the server's UPDATE_OBJECT echoes it. Nothing
|
||
// is notified when it lands either (§4.2: all 49 field-change registrations at `0x468070` were
|
||
// enumerated; none sits at an offset ≥ `0x1000`), so there is **no event to fire here** — the
|
||
// reference reads the binding exactly once, in `UIParent.lua`'s `PLAYER_ENTERING_WORLD`
|
||
// handler, and keeps `SHOW_MULTI_ACTIONBAR_1..4` as its optimistic copy in between.
|
||
//
|
||
// Which is why this sits ABOVE the fire, on 1087's precedent for the XP/rest pushes: the
|
||
// handler that reads `GetActionBarToggles()` runs synchronously inside `fire_event`, so a push
|
||
// below it would hand the first-paint the previous frame's value — four nils.
|
||
//
|
||
// `unwrap_or(0)` is faithful, not a shrug: with no local player the reference's chain fails
|
||
// soft and the getter returns four `nil`s, which is exactly what a zero byte returns — "not in
|
||
// world" and "byte == 0" share the branch and are indistinguishable to Lua (§5).
|
||
if let Some((store, _)) = self_q.iter().next() {
|
||
let toggles = store.0.player_action_bar_toggles().unwrap_or(0);
|
||
if memo.action_bar_toggles != Some(toggles) {
|
||
gate.audit("feed_units", "the action-bar toggle byte");
|
||
memo.action_bar_toggles = Some(toggles);
|
||
script.set_action_bar_toggles(toggles);
|
||
}
|
||
}
|
||
|
||
// Initial pull: fire PLAYER_ENTERING_WORLD once PER WORLD ENTRY so frames do their first
|
||
// paint on their own — gated on our avatar's descriptor EXISTING. 1087 stated the real
|
||
// client's guarantee (the player object lands before this event) and moved the XP/rest
|
||
// pushes above the fire, but the fire itself still went out on frame 1, seconds before
|
||
// login: every one-shot first-paint read empty state, and only consumers with their own
|
||
// diff events recovered. The 1094 live probe caught the one that couldn't — a level-60
|
||
// login read UnitLevel()=0 at the fire and kept the XP strip. With the gate the guarantee
|
||
// is structural for every consumer.
|
||
//
|
||
// The absent arm is the world-EXIT edge (logout / char switch — the self entity despawns
|
||
// with the streamed world): the real client fires this event on *every* world entry, and
|
||
// since 1290 so do we — the frame tree is torn down and rebuilt across that edge, the way
|
||
// the reference does it. So re-arm the fire and forget the player-global diff
|
||
// memories. Forgetting them makes every next-login first sighting re-seed SILENTLY — the
|
||
// byte-verified fresh-CREATE notify silence (1098 §4), now holding per entry: without it a
|
||
// 60→1 char switch latched the max-level rail shown over a level-1 body (1106's live
|
||
// repro), and a normal→rested char switch would misfire "You feel rested." at login.
|
||
if self_pair.is_some() {
|
||
if !memo.entered_world {
|
||
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
|
||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
memo.entered_world = true;
|
||
}
|
||
} else if memo.entered_world {
|
||
gate.audit("feed_units", "the world-exit disarm");
|
||
memo.entered_world = false;
|
||
memo.last_xp = None;
|
||
memo.last_rest = None;
|
||
memo.last_level = None;
|
||
memo.last_combo = None;
|
||
memo.in_combat = None;
|
||
memo.pvp_desired = None;
|
||
// The worn-display pair is a player-global like the rest, and forgetting it is what makes
|
||
// the next character's preference reach the VM at all: the push is an EDGE, so a memo
|
||
// carrying the last body's bits would silently skip a new body that happens to disagree
|
||
// with the VM's fresh "both shown" default (decision 1472).
|
||
memo.worn_hidden = None;
|
||
// Same reason as the worn-display pair: the push is an EDGE, so a memo carrying the last
|
||
// body's byte would skip a new character whose own toggles happen to match it — and this
|
||
// one has no optimistic default to fall back on, only four nils.
|
||
memo.action_bar_toggles = None;
|
||
}
|
||
|
||
for (token, snap) in [
|
||
("player", &player),
|
||
("target", &target),
|
||
("targettarget", &tot),
|
||
] {
|
||
match snap {
|
||
Some(cur) => {
|
||
let prev = memo.last.get(token);
|
||
if prev != Some(cur) {
|
||
gate.audit("feed_units", "a unit-token transition");
|
||
fire_transitions(&mut script, token, prev, cur);
|
||
memo.last.insert(token.to_string(), cur.clone());
|
||
}
|
||
}
|
||
None => {
|
||
// Clearing a token isn't a UNIT_* event; the target frame reacts to
|
||
// PLAYER_TARGET_CHANGED below.
|
||
if memo.last.remove(token).is_some() {
|
||
gate.audit("feed_units", "a unit-token clear");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// PLAYER_TARGET_CHANGED (no args, real WoW's shape) when the selection changes.
|
||
if selection.guid != memo.target_guid {
|
||
gate.audit("feed_units", "the PLAYER_TARGET_CHANGED edge");
|
||
memo.target_guid = selection.guid;
|
||
script.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
}
|
||
|
||
// PLAYER_REGEN_DISABLED/ENABLED: the self in-combat flag transition (`UNIT_FIELD_FLAGS`
|
||
// bit `UNIT_FLAG_IN_COMBAT 0x00080000`, vmangos `UnitDefines.h:564`) — the center combat
|
||
// text's ENTERING/LEAVING_COMBAT feed (decision 0578; the trigger is PROVISIONAL pending
|
||
// the COMBAT_TEXT_UPDATE emission pin).
|
||
if let Some((store, _)) = self_pair {
|
||
let in_combat = store.0.unit_flags() & 0x0008_0000 != 0;
|
||
if memo.in_combat != Some(in_combat) {
|
||
gate.audit("feed_units", "the combat-flag edge");
|
||
let first_sight = memo.in_combat.is_none();
|
||
memo.in_combat = Some(in_combat);
|
||
if !first_sight || in_combat {
|
||
script.fire_event(
|
||
if in_combat {
|
||
"PLAYER_REGEN_DISABLED"
|
||
} else {
|
||
"PLAYER_REGEN_ENABLED"
|
||
},
|
||
vec![],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// The PvP toggle's own feedback (decision 0652). The reference's local-player PLAYER_FLAGS
|
||
// change handler reacts to the PVP_DESIRED bit — and to nothing else about PvP — with two
|
||
// lines: a yellow UI_INFO_MESSAGE toast, then the verbose sentence as a system chat line. It is
|
||
// keyed on the *preference*, not on the flag the icon draws, which is exactly why it matters:
|
||
// toggling OFF changes no visible flag for five minutes, so without these two lines the key
|
||
// reads as dead. The reference only announces its own player (the whole branch sits behind a
|
||
// guid == localPlayer gate) and only on a change, never on the descriptor that first carries it.
|
||
if let Some((store, _)) = self_pair {
|
||
let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0;
|
||
if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) {
|
||
gate.audit("feed_units", "the PvP-desired edge");
|
||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]);
|
||
chat.push_event(ChatEvent::text_only(
|
||
ChatEventKind::System,
|
||
verbose.to_string(),
|
||
));
|
||
}
|
||
memo.pvp_desired = Some(desired);
|
||
}
|
||
|
||
// The worn-display pair (decision 1472): `PLAYER_FLAGS`' two hide bits, mirrored into the VM
|
||
// so `ShowingHelm()`/`ShowingCloak()` — the Options rows' getters — read the server's truth.
|
||
// On the EDGE, not per frame: the setter flips the VM's belief the instant the box is clicked,
|
||
// and the descriptor only catches up a round trip later. Re-pushing the stale pair in between
|
||
// would un-click the box and make a second click compute the wrong flip.
|
||
if let Some((store, _)) = self_pair {
|
||
let hidden = (store.0.player_hides_helm(), store.0.player_hides_cloak());
|
||
if memo.worn_hidden != Some(hidden) {
|
||
gate.audit("feed_units", "the worn-display pair");
|
||
memo.worn_hidden = Some(hidden);
|
||
script.set_worn_display(!hidden.0, !hidden.1);
|
||
}
|
||
}
|
||
|
||
// The combo-point feed: `PLAYER_FIELD_BYTES` byte 1 and the `PLAYER_FIELD_COMBO_TARGET` GUID
|
||
// it is banked against, both PRIVATE, pushed as the pair the server writes (0869, 0875).
|
||
//
|
||
// Both halves are PUSHED whenever either moves — `GetComboPoints` reads both, so a stale
|
||
// target would make it lie on the next call. Only the COUNT fires the event: the client's
|
||
// `PLAYER_COMBO_POINTS` (event 202, `0x5ddff0`) is registered at `0x5dd9d9` as a **one-byte
|
||
// field-change watch on `+0x1029`** and nothing else — the combo-target field has no watch of
|
||
// its own (0879). The watch carries no value test, so it fires on the drop back to zero too,
|
||
// which is the edge `ComboFrame` needs to hide itself when the server's 4 s
|
||
// `REACTIVE_OVERPOWER` timer clears the point.
|
||
//
|
||
// The reader's other input — which unit is selected — reaches it through
|
||
// `PLAYER_TARGET_CHANGED` above, the event the reference registers `ComboFrame` for precisely
|
||
// because `GetComboPoints` consults the current target.
|
||
if let Some((store, _)) = self_q.iter().next() {
|
||
let banked = (
|
||
store.0.player_combo_points().unwrap_or(0),
|
||
store.0.player_combo_target(),
|
||
);
|
||
if let Some(fire) = combo_edge(memo.last_combo, banked) {
|
||
gate.audit("feed_units", "the combo-point edge");
|
||
memo.last_combo = Some(banked);
|
||
script.set_combo_points(banked.0, banked.1);
|
||
if fire {
|
||
script.fire_event("PLAYER_COMBO_POINTS", vec![]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The combo feed's edge, given the last `(count, banked-target)` pair pushed and the current one:
|
||
/// `None` = nothing moved, `Some(fire)` = push the pair, and whether `PLAYER_COMBO_POINTS` fires.
|
||
///
|
||
/// The two halves diverge because the client's watches do. Event 202 is registered at `0x5dd9d9`
|
||
/// as a **one-byte field-change watch on `+0x1029`** — the count — and `PLAYER_FIELD_COMBO_TARGET`
|
||
/// carries no watch of its own (§5-VERIFIED, decision 0879). So a re-bank onto a different unit at
|
||
/// an unchanged count moves the *value* `GetComboPoints` reads without announcing itself, and the
|
||
/// UI hears about it through `PLAYER_TARGET_CHANGED` instead. The watch has no value test, so the
|
||
/// drop back to zero fires like any other change — the edge that takes the dots down.
|
||
fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
|
||
(last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// **The tapped bit fires `UNIT_FACTION`, and without this the verbs are decorative.**
|
||
///
|
||
/// `UnitIsTapped` answering correctly is only half of it. pfUI's grey-bar branch lives inside
|
||
/// `RefreshUnit`, which is event-driven — so a mob that becomes someone else's tap would stay
|
||
/// full-colour until something unrelated happened to repaint the frame. The predicate would be
|
||
/// right and the screen would be wrong, which no test of the predicate can see (`method.md`
|
||
/// step 5: the mechanism existing is not the mechanism applying).
|
||
///
|
||
/// The reference dispatches event id **29** — `UNIT_FACTION`, not a tapped-specific event —
|
||
/// from its `UNIT_DYNAMIC_FLAGS` watcher's bit-`0x4` arm (`0x6005a1 test al,4` ->
|
||
/// `0x6005b0 mov edx,0x1d` -> `0x515e50`; wow-re `ui/scratch/tapped-bits-and-unit-faction.md`,
|
||
/// controlled across all 37 fire sites image-wide). Reusing the faction event for a
|
||
/// non-faction field is not something anyone would invent, which is exactly why it is pinned.
|
||
///
|
||
/// Bit `0x8` has **no** arm, in the reference or here — proven there by enumerating all 122
|
||
/// instructions and 12 branches of the watcher. The negative half is asserted too, because
|
||
/// "fire on both, it's cheaper" is the obvious wrong simplification.
|
||
#[test]
|
||
fn the_tapped_bit_fires_unit_faction_and_the_by_player_bit_fires_nothing() {
|
||
let fired = |prev: UnitState, cur: UnitState| -> Vec<String> {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
SEEN = {}
|
||
local f = CreateFrame("Frame")
|
||
f:RegisterEvent("UNIT_FACTION")
|
||
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fire_transitions(&mut s, "target", Some(&prev), &cur);
|
||
s.eval::<Vec<String>>("return SEEN").unwrap()
|
||
};
|
||
let base = UnitState {
|
||
exists: true,
|
||
has_object: true,
|
||
..Default::default()
|
||
};
|
||
|
||
// The 0x4 edge fires it.
|
||
assert_eq!(
|
||
fired(
|
||
base.clone(),
|
||
UnitState {
|
||
tapped: true,
|
||
..base.clone()
|
||
}
|
||
),
|
||
vec!["UNIT_FACTION".to_string()],
|
||
"a unit becoming tapped must repaint the frames that draw it"
|
||
);
|
||
// ...and back the other way, when the tap is released.
|
||
assert_eq!(
|
||
fired(
|
||
UnitState {
|
||
tapped: true,
|
||
..base.clone()
|
||
},
|
||
base.clone()
|
||
),
|
||
vec!["UNIT_FACTION".to_string()],
|
||
);
|
||
// The 0x8 edge fires NOTHING — the reference's watcher has no arm for it.
|
||
assert!(
|
||
fired(
|
||
UnitState {
|
||
tapped: true,
|
||
..base.clone()
|
||
},
|
||
UnitState {
|
||
tapped: true,
|
||
tapped_by_player: true,
|
||
..base.clone()
|
||
}
|
||
)
|
||
.is_empty(),
|
||
"bit 0x8 has no delta arm — firing on it would be an invention"
|
||
);
|
||
// Nothing moved: nothing fires. The control that stops this passing by firing always.
|
||
assert!(fired(base.clone(), base.clone()).is_empty());
|
||
}
|
||
|
||
/// **Report B304 — Quiver's range indicator read Dead Zone on a far target.**
|
||
///
|
||
/// Its Dead Zone rung is `IsActionInRange(AutoShot) ~= 1` **and**
|
||
/// `CheckInteractDistance("target", 4)` truthy (`Quiver.bundle.lua:3136-3150`), i.e. "cannot
|
||
/// shoot, but inside 30 yards". The reach map only ever held *players*, so a creature
|
||
/// `"target"` never entered it and the 30-yard rung answered a **constant** for every mob at
|
||
/// every distance: "in range" at the published tag (the old permissive default), and — after
|
||
/// 1564 flipped that default for B316 — "out of range" for a mob standing next to you. Two
|
||
/// different wrong answers from one defect: the map was never asked about creatures at all.
|
||
///
|
||
/// So this drives the feed with a real creature target at three distances and reads the rung
|
||
/// itself. The player-only leg it used to apply at the token now rides in the entry, which the
|
||
/// `CanInspect` assertions pin: a boar three yards away is in interact range and is still not
|
||
/// inspectable.
|
||
#[test]
|
||
fn a_creature_target_enters_the_reach_map_at_its_real_distance() {
|
||
use benilla_protocol::messages::ObjectFields;
|
||
use bevy::ecs::system::RunSystemOnce;
|
||
|
||
/// `UNIT_FIELD_FLAGS` bit 1 (NON_ATTACKABLE) — one of `can_attack`'s five disqualifiers,
|
||
/// so a unit carrying it is `inspectable` as far as the entry is concerned.
|
||
const NON_ATTACKABLE: u32 = 1 << 1;
|
||
const FIELD_UNIT_FLAGS: u16 = 46;
|
||
const ME: u64 = 0x0000_0000_0000_0001;
|
||
const BOAR: u64 = 0xF130_0000_0000_0002;
|
||
const FRIEND: u64 = 0x0000_0000_0000_0003;
|
||
|
||
/// Seat one target `yards` away and answer `expr` against it.
|
||
fn ask(guid: u64, flags: u32, yards: f32, expr: &str) -> bool {
|
||
let mut app = App::new();
|
||
app.init_resource::<crate::ui_party::GroupState>()
|
||
.init_resource::<Reputations>();
|
||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((
|
||
SelfPlayer,
|
||
Guid(ME),
|
||
ObjectStore(ObjectFields::default()),
|
||
Transform::from_xyz(0.0, 0.0, 0.0),
|
||
))
|
||
.id();
|
||
let target = app
|
||
.world_mut()
|
||
.spawn((
|
||
Guid(guid),
|
||
ObjectStore(ObjectFields::from_pairs(&[(FIELD_UNIT_FLAGS, flags)])),
|
||
// Along one axis, so d² is exactly yards² and the thresholds are readable.
|
||
Transform::from_xyz(yards, 0.0, 0.0),
|
||
))
|
||
.id();
|
||
app.insert_resource(crate::net::GuidIndex(
|
||
[(ME, me), (guid, target)].into_iter().collect(),
|
||
));
|
||
app.insert_resource(Selection {
|
||
target: Some(target),
|
||
guid: Some(guid),
|
||
});
|
||
|
||
app.world_mut().run_system_once(feed_unit_reach).unwrap();
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<bool>(expr)
|
||
.unwrap()
|
||
}
|
||
|
||
// The report itself: a mob well past 30 yards is OUT of the 30-yard row, so Quiver's
|
||
// "inside 30 but cannot shoot" conjunction is false and the bar reads Out of Range.
|
||
assert!(
|
||
ask(
|
||
BOAR,
|
||
0,
|
||
40.0,
|
||
r#"return CheckInteractDistance("target", 4) == nil"#
|
||
),
|
||
"a creature 40 yards away is out of the 30-yard row"
|
||
);
|
||
// …and the same rung still says YES inside the band, which is the half a token that is
|
||
// simply absent can never do — before this, no mob at any distance could light it.
|
||
assert!(
|
||
ask(
|
||
BOAR,
|
||
0,
|
||
15.0,
|
||
r#"return CheckInteractDistance("target", 4) ~= nil"#
|
||
),
|
||
"a creature 15 yards away is inside the 30-yard row"
|
||
);
|
||
assert!(
|
||
ask(
|
||
BOAR,
|
||
0,
|
||
15.0,
|
||
r#"return CheckInteractDistance("target", 1) == nil"#
|
||
),
|
||
"…and outside the 10-yard one: the table is indexed, not a constant"
|
||
);
|
||
|
||
// The typemask moved into the entry rather than vanishing: a creature in interact range is
|
||
// still not something `CanInspect` says yes to.
|
||
assert!(
|
||
ask(
|
||
BOAR,
|
||
0,
|
||
3.0,
|
||
r#"return CheckInteractDistance("target", 1) ~= nil"#
|
||
),
|
||
"a creature 3 yards away is in interact range"
|
||
);
|
||
assert!(
|
||
ask(BOAR, 0, 3.0, r#"return CanInspect("target") == nil"#),
|
||
"…and is still not inspectable — the players-only leg rides in the entry"
|
||
);
|
||
// The control: a non-attackable PLAYER at the same spot is inspectable, so the assertion
|
||
// above is about the guid type and not about the map having gone dark.
|
||
assert!(
|
||
ask(
|
||
FRIEND,
|
||
NON_ATTACKABLE,
|
||
3.0,
|
||
r#"return CanInspect("target") ~= nil"#
|
||
),
|
||
"a non-attackable player 3 yards away is inspectable"
|
||
);
|
||
}
|
||
|
||
/// The combo feed pushes on either half moving but speaks only for the count — the client's
|
||
/// watch is on that byte alone (decision 0879).
|
||
#[test]
|
||
fn only_the_count_fires_the_combo_event() {
|
||
const A: u64 = 0xF130_0000_0000_0001;
|
||
const B: u64 = 0xF130_0000_0000_0002;
|
||
|
||
assert_eq!(combo_edge(Some((1, A)), (1, A)), None, "nothing moved");
|
||
assert_eq!(
|
||
combo_edge(Some((1, A)), (2, A)),
|
||
Some(true),
|
||
"a builder lands: push and speak"
|
||
);
|
||
assert_eq!(
|
||
combo_edge(Some((5, A)), (0, 0)),
|
||
Some(true),
|
||
"the clear speaks too — the falling edge is what hides the dots"
|
||
);
|
||
assert_eq!(
|
||
combo_edge(Some((1, A)), (1, B)),
|
||
Some(false),
|
||
"re-banked onto another unit at the same count: pushed, but silent like the client"
|
||
);
|
||
assert_eq!(
|
||
combo_edge(None, (0, 0)),
|
||
Some(true),
|
||
"first sight announces once, as the descriptor block's first write does"
|
||
);
|
||
}
|
||
|
||
/// **A feigning hunter's frame reads as a corpse's** (decision 1022) — the symptom the whole
|
||
/// change exists for: the wire says nothing but `UNIT_DYNFLAG_DEAD`, and the snapshot has to
|
||
/// turn that into empty bars against a real maximum plus `UnitIsDead`. Asserted through
|
||
/// `snapshot` rather than the field getters (which have their own byte-law test) because what
|
||
/// can regress here is the *wiring* — a future edit reaching for `unit_health` again.
|
||
#[test]
|
||
fn a_feigning_unit_snapshots_empty_bars_over_a_real_maximum() {
|
||
use benilla_protocol::messages::ObjectFields;
|
||
|
||
/// `UNIT_FIELD_HEALTH` / `MAXHEALTH` / `POWER1` / `MAXPOWER1` / `DYNAMIC_FLAGS`.
|
||
const HEALTH: u16 = 22;
|
||
const POWER1: u16 = 23;
|
||
const MAXHEALTH: u16 = 28;
|
||
const MAXPOWER1: u16 = 29;
|
||
const DYNFLAGS: u16 = 143;
|
||
|
||
let vitals = [
|
||
(HEALTH, 1200),
|
||
(MAXHEALTH, 1500),
|
||
(POWER1, 300),
|
||
(MAXPOWER1, 900),
|
||
];
|
||
let alive = snapshot(
|
||
&ObjectStore(ObjectFields::from_pairs(&vitals)),
|
||
Some("Hunter".into()),
|
||
0,
|
||
None,
|
||
);
|
||
assert_eq!((alive.health, alive.max_health), (1200, 1500));
|
||
assert_eq!((alive.power, alive.max_power), (300, 900));
|
||
assert!(!alive.dead);
|
||
|
||
let feigning = snapshot(
|
||
&ObjectStore(ObjectFields::from_pairs(
|
||
&[vitals.as_slice(), &[(DYNFLAGS, 0x20)]].concat(),
|
||
)),
|
||
Some("Hunter".into()),
|
||
0,
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
(feigning.health, feigning.max_health),
|
||
(0, 1500),
|
||
"UnitHealth 0x5174d0 zeroes, UnitHealthMax 0x5175b0 does not — an EMPTY bar, not a gone one"
|
||
);
|
||
assert_eq!(
|
||
(feigning.power, feigning.max_power),
|
||
(0, 900),
|
||
"UnitMana 0x517670 zeroes, UnitManaMax 0x5177e0 does not"
|
||
);
|
||
assert!(feigning.dead, "UnitIsDead 0x517ac0's dynflag leg");
|
||
assert!(
|
||
!feigning.ghost,
|
||
"feign is not a ghost — PLAYER_FLAGS is clear"
|
||
);
|
||
|
||
// …and the flag moves the two fields [`fire_transitions`] diffs, so the edge announces
|
||
// itself as `UNIT_HEALTH` + the power event — the very pair the reference's own dynamic-
|
||
// flags watcher fires there (`0x6004c5`, `0x6004f0`). Nothing else about the unit moved,
|
||
// which is why routing the flag through the getters is enough: no extra watcher needed.
|
||
assert_ne!(alive.health, feigning.health);
|
||
assert_ne!(alive.power, feigning.power);
|
||
assert_eq!(
|
||
(alive.max_health, alive.max_power, alive.level),
|
||
(feigning.max_health, feigning.max_power, feigning.level),
|
||
);
|
||
}
|
||
|
||
/// The rank getter's two gates (`0x605620`, decision 0782) as they reach a snapshot. The pet
|
||
/// gate is the interesting half: a charmed or enslaved elite reports rank 0, so it loses its
|
||
/// dragon border AND its ELITE tooltip word AND (at rank 3) its world-boss skull together.
|
||
/// Asserted through `enrich_unit` rather than `gated_rank` directly, because the thing that
|
||
/// can regress is the *wiring* — a future edit reaching for `rec.rank` again.
|
||
#[test]
|
||
fn the_rank_gate_zeroes_a_pet_or_charm() {
|
||
use benilla_protocol::messages::ObjectFields;
|
||
|
||
const ENTRY: u32 = 12397; // Ol' Sooty, a rank-1 elite
|
||
const GUID: u64 = (0xF130u64 << 48) | ((ENTRY as u64) << 24) | 0x42;
|
||
/// `UNIT_FIELD_PETNUMBER` — absolute descriptor index (`OBJECT_END(6) + 0x85`).
|
||
const PETNUMBER: u16 = 139;
|
||
|
||
let mut names = NameCache::default();
|
||
names.insert_creature(
|
||
ENTRY,
|
||
Some(crate::names::CreatureRecord {
|
||
name: "Ol' Sooty".into(),
|
||
subname: None,
|
||
creature_type: 1,
|
||
pet_family: 4, // Bear — a real tameable family, so the record is a plausible one
|
||
rank: 1,
|
||
type_flags: 0,
|
||
civilian: false,
|
||
racial_leader: false,
|
||
display_id: 0,
|
||
}),
|
||
);
|
||
|
||
let rank_of = |fields: &[(u16, u32)]| {
|
||
let store = ObjectStore(ObjectFields::from_pairs(fields));
|
||
let mut s = UnitState::default();
|
||
enrich_unit(&mut s, GUID, &names, &store, None, None);
|
||
s.rank
|
||
};
|
||
|
||
assert_eq!(rank_of(&[]), 1, "a free elite keeps its template rank");
|
||
assert_eq!(
|
||
rank_of(&[(PETNUMBER, 0)]),
|
||
1,
|
||
"an explicit zero pet number is not a pet"
|
||
);
|
||
assert_eq!(
|
||
rank_of(&[(PETNUMBER, 7)]),
|
||
0,
|
||
"a non-zero pet number forces rank 0 — no dragon on an enslaved elite"
|
||
);
|
||
|
||
// The record gate, the getter's other half: no cached template at all → rank 0, and the
|
||
// border stays plain until the creature query answers.
|
||
let store = ObjectStore(ObjectFields::from_pairs(&[]));
|
||
let mut s = UnitState::default();
|
||
enrich_unit(&mut s, GUID ^ (1 << 24), &names, &store, None, None);
|
||
assert_eq!(s.rank, 0, "an un-queried creature has no classification");
|
||
}
|
||
|
||
/// 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.
|
||
#[test]
|
||
fn pvp_announcement_speaks_only_on_an_edge() {
|
||
assert_eq!(
|
||
pvp_announcement(None, false),
|
||
None,
|
||
"first sight, unflagged"
|
||
);
|
||
assert_eq!(pvp_announcement(None, true), None, "first sight, flagged");
|
||
assert_eq!(pvp_announcement(Some(true), true), None, "no change");
|
||
assert_eq!(pvp_announcement(Some(false), false), None, "no change");
|
||
|
||
let (toast, verbose) = pvp_announcement(Some(false), true).expect("turning it on speaks");
|
||
assert_eq!(toast, "PvP combat toggled on"); // GlobalStrings ERR_PVP_TOGGLE_ON
|
||
assert_eq!(
|
||
verbose,
|
||
"You are now flagged for PvP combat and will remain so until toggled off."
|
||
);
|
||
|
||
let (toast, verbose) = pvp_announcement(Some(true), false).expect("turning it off speaks");
|
||
assert_eq!(toast, "PvP combat toggled off"); // GlobalStrings ERR_PVP_TOGGLE_OFF
|
||
assert!(
|
||
verbose.contains("five minutes"),
|
||
"the OFF sentence is what tells the player the flag lingers: {verbose}"
|
||
);
|
||
}
|
||
|
||
/// The rest-state chat law (decision 1098, wow-re §§6-10): a message needs a real byte
|
||
/// TRANSITION, and only states 1/2 speak — state 0 is the pair table's no-message sentinel,
|
||
/// the beta tiers (≥3) are gated off before the table, and a re-send of the same byte is
|
||
/// swallowed by the dispatcher's mirror diff.
|
||
#[test]
|
||
fn rest_state_message_speaks_only_on_a_real_transition() {
|
||
assert_eq!(rest_state_message(2, 1), Some("You feel rested."));
|
||
assert_eq!(rest_state_message(1, 2), Some("You feel normal."));
|
||
assert_eq!(
|
||
rest_state_message(0, 1),
|
||
Some("You feel rested."),
|
||
"0→1 IS a transition"
|
||
);
|
||
assert_eq!(
|
||
rest_state_message(1, 1),
|
||
None,
|
||
"same byte re-sent — the mirror diff eats it"
|
||
);
|
||
assert_eq!(rest_state_message(2, 2), None);
|
||
assert_eq!(
|
||
rest_state_message(1, 0),
|
||
None,
|
||
"state 0 is the 0x1d1 sentinel: no message"
|
||
);
|
||
assert_eq!(
|
||
rest_state_message(2, 3),
|
||
None,
|
||
"beta tiers are gated off (cmp esi,3; jae)"
|
||
);
|
||
assert_eq!(rest_state_message(1, 5), None);
|
||
}
|
||
}
|