breath and fatigue bars, combo points, and a stun stops everything

- the breath and fatigue bars are in, driven by the server's timers
- combo points show on the target frame, rogue and druid only, per target, and Overpower lights on them
- a stun freezes turning too, and gm fly over water stays in the air
- /sit and the shipped slash commands work, sheathing sounds like the weapon's material, and the bar never holds an outgrown spell rank

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:01:15 +07:00
co-authored by Claude Fable 5
parent 143a734b91
commit a3c5b20698
58 changed files with 5204 additions and 725 deletions
+38
View File
@@ -14,6 +14,11 @@
//! `AnimID` (column 2) feeds both the one-shot anim-emote path (`SMSG_EMOTE`'s `Emotes.dbc` id) and
//! the looping state-emote idle (`UNIT_NPC_EMOTESTATE`, the same id space) — [`EmoteSoundCatalog::anim`].
//!
//! `EmoteSpecProc`/`EmoteSpecProcParam` (columns 4/5) carry the **posture** half of the client's
//! `DoEmote`: proc `1` means "this emote sets a stand state", and the param IS that state
//! ([`EmoteSoundCatalog::posture_state`]) — the mechanism behind `/sit` (proc `2` is the looping
//! state emote whose `EventSoundID` the `$ESD` event rings).
//!
//! `EmoteFlags` (column 3) feeds the **send-side posture-eligibility gate** — the only `EmoteFlags`
//! consumer, byte-verified at the real client's `CheckEmoteEligible` (`0x47db40`, called from
//! `DoEmote` `0x5ef560`): before `CMSG_TEXT_EMOTE` goes out, bit `0x0001` combined with a non-zero
@@ -47,6 +52,9 @@ pub struct EmoteSoundCatalog {
/// `EventSoundID` the `$ESD` anim event rings (the client's `row[+0x10] == 2` gate at the
/// `$ESD` handler `0x6239f0` — wow-re `sound/scratch/gather-sound-anim-events.md`).
spec_proc: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteSpecProcParam` (column 5). For `EmoteSpecProc == 1` this is the
/// **stand state** the emote sets — see [`EmoteSoundCatalog::posture_state`].
spec_proc_param: HashMap<u32, u32>,
}
impl EmoteSoundCatalog {
@@ -92,6 +100,20 @@ impl EmoteSoundCatalog {
self.spec_proc.get(&emote_id).copied()
}
/// The **stand state** a POSTURE emote sets: `EmoteSpecProcParam` gated on `EmoteSpecProc == 1`
/// (`None` for every other emote). This is the client's `DoEmote` state branch — wow-re
/// `object-layer/scratch/emote-posture-gate.md` §1: `if (rec.EmoteSpecProc == 1 && …)
/// SetStandState(rec.SpecProcParam)`, the same `0x5ed430` setter the sit key drives. The five
/// reachable rows: STATE_SIT(13)→1, STATE_SLEEP(12)→3, STATE_KNEEL(68)→8, STATE_STAND(26)→0,
/// STATE_AT_EASE(313)→2 — which is why `/sit` sits at all, since the *server* deliberately does
/// nothing for a STATE text emote (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`: SIT /
/// SLEEP / KNEEL / NONE break out before `HandleEmote`).
pub fn posture_state(&self, emote_id: u32) -> Option<u32> {
(self.spec_proc.get(&emote_id).copied() == Some(1))
.then(|| self.spec_proc_param.get(&emote_id).copied())
.flatten()
}
pub fn len(&self) -> usize {
self.by_name.len()
}
@@ -152,6 +174,7 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
let mut anim = HashMap::with_capacity(rs.records().len());
let mut emote_flags = HashMap::with_capacity(rs.records().len());
let mut spec_proc = HashMap::with_capacity(rs.records().len());
let mut spec_proc_param = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(kit) = u32_at(r, 6) {
@@ -166,6 +189,9 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
if let Some(proc) = u32_at(r, 4) {
spec_proc.insert(id, proc);
}
if let Some(param) = u32_at(r, 5) {
spec_proc_param.insert(id, param);
}
}
Ok(EmoteSoundCatalog {
@@ -176,6 +202,7 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
anim,
emote_flags,
spec_proc,
spec_proc_param,
})
}
@@ -233,6 +260,17 @@ mod tests {
);
assert_eq!(cat.anim(233), Some(136), "mining state plays anim 136");
assert_eq!(cat.spec_proc(2), Some(0), "ONESHOT_BOW EmoteSpecProc");
// The posture branch (`DoEmote`'s `EmoteSpecProc == 1` → `SetStandState(param)`): the four
// stand states a slash emote can reach, byte-decoded off the shipped table. The values are
// vmangos `UnitStandStateType` (STAND 0 · SIT 1 · SLEEP 3 · KNEEL 8).
assert_eq!(cat.posture_state(13), Some(1), "STATE_SIT sets SIT");
assert_eq!(cat.posture_state(12), Some(3), "STATE_SLEEP sets SLEEP");
assert_eq!(cat.posture_state(68), Some(8), "STATE_KNEEL sets KNEEL");
assert_eq!(cat.posture_state(26), Some(0), "STATE_STAND sets STAND");
// A proc-2 state emote and a one-shot are NOT posture emotes, however tempting their param
// looks: the gate is the proc column, not the param's presence.
assert_eq!(cat.posture_state(233), None, "mining state is proc 2");
assert_eq!(cat.posture_state(2), None, "ONESHOT_BOW is proc 0");
assert!(
cat.voice
.keys()
+60 -6
View File
@@ -33,12 +33,16 @@ pub struct SheatheSoundCatalog {
}
impl SheatheSoundCatalog {
/// The kit pair for an item, preferring the exact material row and falling back to any
/// material carried for that `(class, subclass)` (players' item material isn't on the wire —
/// the caller passes its best guess), then to the class's subclass-0 row: the 5875 table
/// only covers weapon subclasses 0–14, so daggers (15) and other stragglers ring like a
/// generic weapon of their material (INTERIM — the client's hash-miss behavior is
/// unrecorded).
/// The kit pair for an item. **`material` is the load-bearing argument** — the weapon rows
/// agree within a material and differ across one, so the class/subclass only choose the
/// *family* (weapon vs shield). Callers pass the item's real `Material` off the wire; it is
/// never inferred from the subclass, which carries no material information (decision 0882).
///
/// Falls back to any material carried for that `(class, subclass)` — this is what lands a
/// shield, whose own rows hold material 0 as a don't-care, on the shield pair — and then to
/// the class's subclass-0 row: the 5875 table only covers weapon subclasses 0–14, so daggers
/// (15) and other stragglers ring like a generic weapon of their material (INTERIM — the
/// client's hash-miss behavior is unrecorded).
pub fn get(&self, class: u32, subclass: u32, material: u32) -> Option<SheatheSounds> {
[subclass, 0]
.iter()
@@ -120,4 +124,54 @@ mod tests {
assert_eq!((dagger.sheathe, dagger.unsheathe), (698, 700));
assert!(cat.get(2, 15, 7).is_some(), "weird material still resolves");
}
/// **The material is the whole key; the subclass is inert** (decision 0882). Every weapon row
/// of a material carries the same kit pair, so a bow (subclass 2, sitting in the middle of the
/// weapon range) rings by what it is *made of* — and the reference client's own `itemcache.wdb`
/// puts every bow, crossbow and wand at material 2 (wood) while guns and thrown are metal.
/// Ringing a bow on the metal pair — which a "staves and polearms are the wooden ones" guess
/// does — is the director-reported wrong sound. Skips without client data.
#[test]
fn the_kit_pair_is_decided_by_material_alone() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_sheathe_sound_catalog(&mut chain).expect("load sheathe sounds");
// A bow is wood, and wood rings 697/699 — never the metal 698/700 pair.
let bow = cat.get(2, 2, 2).expect("wood bow");
assert_eq!((bow.sheathe, bow.unsheathe), (697, 699));
// The same subclass at the other material is the metal pair: subclass carries no
// information here, which is exactly why it must not be used to infer the material.
let metal = cat.get(2, 2, 1).expect("metal row for the same subclass");
assert_eq!((metal.sheathe, metal.unsheathe), (698, 700));
// …and that holds across every weapon subclass the table covers.
for subclass in 0..=14 {
assert_eq!(
cat.get(2, subclass, 2).map(|p| (p.sheathe, p.unsheathe)),
Some((697, 699)),
"subclass {subclass} at material 2 must ring wood"
);
assert_eq!(
cat.get(2, subclass, 1).map(|p| (p.sheathe, p.unsheathe)),
Some((698, 700)),
"subclass {subclass} at material 1 must ring metal"
);
}
// A shield's real material is whatever its armor row says (1 metal and 6 plate both occur
// in the reference cache); its DBC rows carry material 0 as a don't-care, and the
// fallback chain lands them on the shield pair regardless.
for material in [0, 1, 6] {
let shield = cat
.get(4, 6, material)
.expect("shield resolves at any material");
assert_eq!((shield.sheathe, shield.unsheathe), (696, 701));
}
}
}
+151 -4
View File
@@ -24,7 +24,10 @@
//! build) · `forward_spellid`(8) · `learnOnGetSkill`(9) · `max_value`(10) · `min_value`(11) ·
//! `reqtrainpoints`(14; 12-13 unused). Read into one [`SlaInfo`] per spell (a spell can carry
//! more than one row across race/class variants; the FIRST row wins, deterministic by file
//! order — [`SkillLineCatalog::spell_to_line`]'s long-standing convention). `max_value`/
//! order — [`SkillLineCatalog::spell_to_line`]'s long-standing convention), except
//! `forward_spellid`, which takes the first **non-zero** across the spell's rows to match
//! vmangos's own `SpellMgr::GetSpellBookSuccessorSpellId` exactly (identical on this build:
//! probed, 44 spells carry more than one row and not one disagrees on the column). `max_value`/
//! `min_value` are the recipe-difficulty trivial ranks (TrivialSkillLineRankHigh/Low): pinned on
//! the raw 5875 file this session — Bolt of Linen Cloth 2963 → (line 197, req 1, low 25,
//! high 50), Minor Healing Potion 2330 → (171, 1, 55, 95), which reproduces its known classic
@@ -78,9 +81,16 @@ const SKILL_LINE_ABILITY_FIELDS: usize = 15;
const COL_SLA_SKILL_ID: usize = 1;
const COL_SLA_SPELL_ID: usize = 2;
const COL_SLA_REQ_SKILL_VALUE: usize = 7;
const COL_SLA_FORWARD_SPELL: usize = 8;
const COL_SLA_TRIVIAL_HIGH: usize = 10;
const COL_SLA_TRIVIAL_LOW: usize = 11;
/// Hard stop on a rank-chain walk ([`SkillLineCatalog::highest_known_rank`]). The real build-5875
/// data is acyclic and its longest chain is **9** hops (Heroic Strike 78 → … → 25286, probed over
/// every one of the 406 chained spells), so this only ever fires on edited/corrupt DBCs — where a
/// wrong answer beats a hung frame.
const MAX_RANK_CHAIN: usize = 16;
const SKILL_LINE_CATEGORY: &str = "DBFilesClient\\SkillLineCategory.dbc";
const SKILL_LINE_CATEGORY_FIELDS: usize = 11;
const COL_SLC_NAME_ENUS: usize = 1;
@@ -145,6 +155,13 @@ pub struct SlaInfo {
pub skill_id: u32,
/// `req_skill_value` (column 7) — the line rank required to learn/use the ability.
pub req_skill_value: u32,
/// `forward_spellid` (column 8) — the **next rank** of this ability, or 0 when it has none.
/// This column is the whole rank-chain graph: 406 of the 4753 spells carry it, and they are
/// exactly the abilities the server supersedes (vmangos gates every `SMSG_SUPERCEDED_SPELL`
/// it sends on `GetSpellBookSuccessorSpellId`, `Player::AddSpell`) — the warrior/rogue
/// physical lines, the profession tier openers, weapon skills. Caster nukes/heals carry 0:
/// their ranks all stay known and castable, which is what makes vanilla down-ranking work.
pub forward_spell_id: u32,
/// `min_value` (column 11) — TrivialSkillLineRankLow: yellow at-or-above, orange below.
pub trivial_low: u32,
/// `max_value` (column 10) — TrivialSkillLineRankHigh: gray at-or-above; green at-or-above
@@ -162,6 +179,11 @@ pub struct SkillLineCatalog {
/// skill line id → its `SkillRaceClassInfo` rows (empty when the DBC failed to load — then
/// [`Self::spell_tab`] skips the General collapse and keeps each line its own tab).
race_class: HashMap<u32, Vec<SrciRow>>,
/// The `forward_spellid` graph inverted: next rank → the rank before it. Injective on the
/// real build-5875 data (probed: not one of the 406 chained spells is the successor of two
/// different predecessors), so a spell has at most one previous rank and
/// [`Self::chain_head`]'s walk is unambiguous.
rank_prev: HashMap<u32, u32>,
}
impl SkillLineCatalog {
@@ -176,6 +198,59 @@ impl SkillLineCatalog {
self.abilities.get(&spell_id)
}
/// The **next rank** of `spell_id` ([`SlaInfo::forward_spell_id`]), or `None` when the ability
/// doesn't rank up this way — vmangos's `SpellMgr::GetSpellBookSuccessorSpellId`.
pub fn rank_successor(&self, spell_id: u32) -> Option<u32> {
self.abilities
.get(&spell_id)
.map(|a| a.forward_spell_id)
.filter(|&id| id != 0)
}
/// The **first** rank of `spell_id`'s chain — walk [`Self::rank_prev`] back to the spell that
/// nothing forwards to. `spell_id` itself when it heads its own chain (including the common
/// case of no chain at all).
fn chain_head(&self, spell_id: u32) -> u32 {
let mut head = spell_id;
for _ in 0..MAX_RANK_CHAIN {
match self.rank_prev.get(&head) {
Some(&prev) if prev != head => head = prev,
_ => break,
}
}
head
}
/// The **highest rank of `spell_id`'s ability that `known` contains** — the rank an action-bar
/// slot pointing at `spell_id` must actually hold. `None` when no rank of the chain is known
/// (an empty book, or an ability the character never learned): the caller leaves the slot
/// alone rather than pointing it somewhere arbitrary.
///
/// Walks the whole chain from its head, not just forward from `spell_id`, so it answers for a
/// *downgrade* (the bar holds rank 5, the book was pushed back to rank 4) as well as the
/// ordinary rank-up. The server only ever keeps one rank of a chained ability active
/// (vmangos `Player::AddSpell` marks the old one `active = false` and it drops out of
/// `SMSG_INITIAL_SPELLS`), so in practice at most one rank is ever known and "highest" is
/// simply "the one".
pub fn highest_known_rank(
&self,
spell_id: u32,
known: &std::collections::HashSet<u32>,
) -> Option<u32> {
let mut cur = self.chain_head(spell_id);
let mut best = known.contains(&cur).then_some(cur);
for _ in 0..MAX_RANK_CHAIN {
let Some(next) = self.rank_successor(cur) else {
break;
};
cur = next;
if known.contains(&cur) {
best = Some(cur);
}
}
best
}
/// The spellbook **tab** a spell lands in for a character of `race`/`class` (1-based unit
/// bytes): the spell's skill line, unless that line routes to General (decision 0228). Returns
/// `0` (the General tab) when the spell has no skill line, no `SkillRaceClassInfo` row admits
@@ -383,16 +458,29 @@ pub fn load_skill_line_catalog(chain: &mut Chain) -> Result<SkillLineCatalog> {
if let (Some(skill_id), Some(spell_id)) =
(u32_at(r, COL_SLA_SKILL_ID), u32_at(r, COL_SLA_SPELL_ID))
{
// First row wins (module doc): deterministic, and every probed spell in the tests
// below has exactly one row anyway.
abilities.entry(spell_id).or_insert(SlaInfo {
let forward_spell_id = u32_at(r, COL_SLA_FORWARD_SPELL).unwrap_or(0);
// First row wins (module doc): deterministic by file order. 44 of the 4753 spells
// carry more than one row (race/class variants) — every spell probed by the tests
// below has exactly one.
let slot = abilities.entry(spell_id).or_insert(SlaInfo {
skill_id,
req_skill_value: u32_at(r, COL_SLA_REQ_SKILL_VALUE).unwrap_or(0),
forward_spell_id,
trivial_low: u32_at(r, COL_SLA_TRIVIAL_LOW).unwrap_or(0),
trivial_high: u32_at(r, COL_SLA_TRIVIAL_HIGH).unwrap_or(0),
});
// …except the rank link, which takes the first NON-ZERO across the spell's rows —
// vmangos's `GetSpellBookSuccessorSpellId` scans all of them (module doc).
if slot.forward_spell_id == 0 {
slot.forward_spell_id = forward_spell_id;
}
}
}
let rank_prev = abilities
.iter()
.filter(|(_, a)| a.forward_spell_id != 0)
.map(|(&spell, a)| (a.forward_spell_id, spell))
.collect();
let race_class = load_race_class_info(chain);
let categories = load_categories(chain);
@@ -402,6 +490,7 @@ pub fn load_skill_line_catalog(chain: &mut Chain) -> Result<SkillLineCatalog> {
abilities,
categories,
race_class,
rank_prev,
})
}
@@ -627,4 +716,62 @@ mod tests {
// Unknown race/class → no button (the conservative arm).
assert!(!cat.abandonable(164, 0, 0));
}
/// The `forward_spellid` rank graph on the real build-5875 file — the action bar's
/// rank-normalization source (decision 0883). Skips without client data.
#[test]
fn real_rank_chains_resolve_the_highest_known_rank() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_skill_line_catalog(&mut chain).expect("load skill lines");
// Sinister Strike ranks 1..8 — the chain the bug report walked (a level-60 rogue whose
// saved bar still held rank 1 while the book held only rank 8).
const SS: [u32; 8] = [1752, 1757, 1758, 1759, 1760, 8621, 11293, 11294];
for pair in SS.windows(2) {
assert_eq!(
cat.rank_successor(pair[0]),
Some(pair[1]),
"Sinister Strike {} → {}",
pair[0],
pair[1]
);
}
assert_eq!(cat.rank_successor(11294), None, "rank 8 tops the chain");
// Warrior Charge (100 → 6178 → 11578) and Heroic Strike's 9 ranks — the two physical
// families the director named.
assert_eq!(cat.rank_successor(100), Some(6178));
assert_eq!(cat.rank_successor(6178), Some(11578));
assert_eq!(cat.rank_successor(11567), Some(25286));
// From ANY rank, knowing only rank 8, the answer is rank 8 — the walk starts at the
// chain head, so it works downward as well as upward.
let top: std::collections::HashSet<u32> = [11294].into_iter().collect();
for id in SS {
assert_eq!(cat.highest_known_rank(id, &top), Some(11294), "from {id}");
}
// Knowing an intermediate rank resolves to it, not to the top of the chain.
let mid: std::collections::HashSet<u32> = [1759].into_iter().collect();
assert_eq!(cat.highest_known_rank(1752, &mid), Some(1759));
assert_eq!(cat.highest_known_rank(11294, &mid), Some(1759));
// No rank of the chain known → no answer (the caller leaves the slot alone).
assert_eq!(cat.highest_known_rank(1752, &Default::default()), None);
// Caster nukes carry NO forward link — every rank stays known and castable, which is what
// makes vanilla down-ranking work, and what keeps normalization off them. Fireball r1/r2,
// Frostbolt r1, Healing Touch r1, Renew r1, Immolate r1, Lightning Bolt r1.
for id in [133u32, 143, 116, 5185, 139, 348, 403] {
assert_eq!(cat.rank_successor(id), None, "spell {id} is not chained");
let known: std::collections::HashSet<u32> = [id].into_iter().collect();
assert_eq!(cat.highest_known_rank(id, &known), Some(id));
}
// A spell with no `SkillLineAbility` row at all (the auto-attack) is its own chain.
let attack: std::collections::HashSet<u32> = [6603].into_iter().collect();
assert_eq!(cat.rank_successor(6603), None);
assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603));
}
}
+13 -1
View File
@@ -13,7 +13,7 @@ use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete,
QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate,
SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask, TradeStatus,
@@ -904,6 +904,18 @@ pub enum SessionEvent {
},
/// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds.
DuelCountdown { seconds: u32 },
/// One mirror timer started, or was wholly re-stated (`SMSG_START_MIRROR_TIMER`, decision
/// 0874) — the breath / fatigue bars. There is no separate update opcode: the server re-sends
/// this whole packet whenever direction, remaining time or frozen state changes, so a
/// consumer must treat it as idempotent re-statement, not only as a first appearance.
MirrorTimerStart(MirrorTimerStart),
/// Freeze/unfreeze one running mirror timer (`SMSG_PAUSE_MIRROR_TIMER`). vmangos never sends
/// it on purpose — see [`crate::messages::opcode::SMSG_PAUSE_MIRROR_TIMER`].
MirrorTimerPause { kind: u32, paused: bool },
/// One mirror timer is over (`SMSG_STOP_MIRROR_TIMER`) — hide its bar. Sent both when the
/// condition ends *well* (surfacing refills the bar to full, and the server stops it there)
/// and when it stops applying at all (death, leaving the water).
MirrorTimerStop { kind: u32 },
/// The whole friend list (`SMSG_FRIEND_LIST`, decision 0668) — pushed unasked at login and
/// again for every `CMSG_FRIEND_LIST`. Always the complete list, never a delta, so it
/// replaces whatever is held. Names are NOT on the wire (see [`crate::messages::social`]);
@@ -445,6 +445,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
loser,
}],
ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }],
ServerPacket::MirrorTimerStart(start) => vec![SessionEvent::MirrorTimerStart(start)],
ServerPacket::MirrorTimerPause { kind, paused } => {
vec![SessionEvent::MirrorTimerPause { kind, paused }]
}
ServerPacket::MirrorTimerStop { kind } => vec![SessionEvent::MirrorTimerStop { kind }],
ServerPacket::FriendList { friends } => vec![SessionEvent::FriendList { friends }],
ServerPacket::IgnoreList { guids } => vec![SessionEvent::IgnoreList { guids }],
ServerPacket::FriendStatus(status) => vec![SessionEvent::FriendStatus(status)],
@@ -0,0 +1,244 @@
//! The mirror timers — the breath / fatigue / feign-death bars (opcodes 473-475; decision 0874).
//!
//! "Mirror" is the client's own word for a timer whose authority is the **server**: the client
//! never computes breath or fatigue, it mirrors a countdown the server ships and integrates it
//! locally between packets. Three opcodes carry the whole system:
//!
//! | opcode | what it means |
//! |---|---|
//! | `SMSG_START_MIRROR_TIMER` 0x1D9 | start **or fully re-state** one timer |
//! | `SMSG_PAUSE_MIRROR_TIMER` 0x1DA | freeze/unfreeze one running timer |
//! | `SMSG_STOP_MIRROR_TIMER` 0x1DB | that timer is over — hide its bar |
//!
//! The bodies are VERIFIED against vmangos's own packet builds
//! (`Server/Packets/Misc.h:718-751` + `Misc.cpp:472-491`), which are the exact bytes benilla
//! receives, and the type numbering against `Objects/MirrorTimer.h`
//! (`FATIGUE=0, BREATH=1, FEIGNDEATH=2, NUM_CLIENT_TIMERS=3`) — the server's `SendMirrorTimers`
//! gate means a **fourth** type (its internal `ENVIRONMENTAL`, lava/slime damage) is never sent
//! to a client at all; it drives damage server-side with no bar.
//!
//! Two things about this family are worth knowing before reading the app side:
//!
//! - **There is no separate "update" opcode.** A running timer that changes anything —
//! direction, remaining time, frozen state — is re-sent as a whole `START`
//! (`Player::SendMirrorTimers`, the `FULL_UPDATE` arm). So the consumer must treat `START` as
//! idempotent *re-statement*, not only as a first appearance.
//! - **`PAUSE` is effectively dead on a vanilla wire.** vmangos deliberately never sends it —
//! `SendMirrorTimers` replaces the pause with a full `START` and says why in the source
//! ("Gotta do a full update with SMSG_START_MIRROR_TIMER to avoid lua errors"), because the
//! shipped 1.12 `MirrorTimer.lua` handler tests the same `arg1` both as a name and as a number.
//! We still decode it — a server that sends one should not desync us (see
//! `crate::ui_mirror` for what the app does with it).
use std::io::{self, Read};
use crate::wire::{read_i32_le, read_u32_le, read_u8};
/// Which mirror timer a packet is about — the wire's `timerType` word.
///
/// The numbering is the server's `MirrorTimer::Type` (vmangos `Objects/MirrorTimer.h`), and it is
/// also the index the reference client uses into its own 3-entry name table, so a value outside
/// this set has no meaning on either end.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirrorTimerKind {
/// `0` — swimming in "high sea" (deep, uncharted water): the **Fatigue** bar.
Fatigue,
/// `1` — head under the surface: the **Breath** bar.
Breath,
/// `2` — feigning death. The server starts this one from a duration that is zero unless a
/// script set it, so vanilla play essentially never shows it; it is a client timer type all
/// the same.
FeignDeath,
}
impl MirrorTimerKind {
/// Map the wire's `timerType` word, or `None` for a type this client has no bar for
/// (the server's own `NUM_CLIENT_TIMERS` gate means vanilla never sends one).
pub fn from_wire(value: u32) -> Option<Self> {
match value {
0 => Some(Self::Fatigue),
1 => Some(Self::Breath),
2 => Some(Self::FeignDeath),
_ => None,
}
}
/// The `timerType` word this kind is sent as — the inverse of [`Self::from_wire`].
pub fn to_wire(self) -> u32 {
match self {
Self::Fatigue => 0,
Self::Breath => 1,
Self::FeignDeath => 2,
}
}
}
/// `SMSG_START_MIRROR_TIMER` — start, or wholly re-state, one mirror timer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MirrorTimerStart {
/// The wire's raw `timerType`. Kept raw rather than pre-mapped so an unknown type survives
/// the decode as data instead of failing the whole packet; [`MirrorTimerKind::from_wire`]
/// is the mapping, applied at the UI seam.
pub kind: u32,
/// Time left, in **milliseconds**. Counts toward `0` while draining and toward `duration_ms`
/// while refilling; the bar is `remaining_ms / 1000` in bar units.
pub remaining_ms: u32,
/// The timer's full span, in **milliseconds** — the bar's maximum (60 s of breath by
/// default: vmangos `CONFIG_UINT32_MIRRORTIMER_BREATH_MAX`).
pub duration_ms: u32,
/// Rate of change, in bar-units per second, and **signed**: `-1` while the timer drains
/// (underwater, in high sea), `+10` while it refills (surfaced — a full breath bar refills
/// ten times faster than it drained, which is why surfacing snaps it back). The consumer
/// integrates `scale * elapsed` between packets; this is the whole of the client-side motion.
pub scale: i32,
/// Frozen: the bar holds its value and stops integrating. Sent as one byte.
pub paused: bool,
/// The spell driving this timer, if any (a water-breathing aura owns the breath timer for its
/// duration). `0` = no spell. The reference client's Lua never sees this field.
pub spell_id: u32,
}
/// Read `SMSG_START_MIRROR_TIMER`: `u32 type, u32 remaining, u32 duration, i32 scale, u8 paused,
/// u32 spellId` — VERIFIED against vmangos `StartMirrorTimer::AppendBodyTo` (`Misc.cpp:472`),
/// field for field in that order. `scale` is the one signed field.
pub fn read_start_mirror_timer(r: &mut impl Read) -> io::Result<MirrorTimerStart> {
Ok(MirrorTimerStart {
kind: read_u32_le(r)?,
remaining_ms: read_u32_le(r)?,
duration_ms: read_u32_le(r)?,
scale: read_i32_le(r)?,
paused: read_u8(r)? != 0,
spell_id: read_u32_le(r)?,
})
}
/// Read `SMSG_PAUSE_MIRROR_TIMER`: `u32 type, u8 paused` (vmangos `PauseMirrorTimer::AppendBodyTo`,
/// `Misc.cpp:487`). Returns the raw type and the flag.
pub fn read_pause_mirror_timer(r: &mut impl Read) -> io::Result<(u32, bool)> {
Ok((read_u32_le(r)?, read_u8(r)? != 0))
}
/// Read `SMSG_STOP_MIRROR_TIMER`: the bare `u32 type` and nothing else
/// (vmangos `StopMirrorTimer::AppendBodyTo`, `Misc.cpp:482`).
pub fn read_stop_mirror_timer(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
#[cfg(test)]
mod tests {
use super::*;
/// A golden `SMSG_START_MIRROR_TIMER` body, byte-for-byte as vmangos writes it: the breath
/// timer draining with 45 s left of a 60 s span, unfrozen, no spell.
#[test]
fn start_reads_every_field_in_the_servers_order() {
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_le_bytes()); // type: BREATH
body.extend_from_slice(&45_000u32.to_le_bytes()); // remaining ms
body.extend_from_slice(&60_000u32.to_le_bytes()); // duration ms
body.extend_from_slice(&(-1i32).to_le_bytes()); // scale: draining
body.push(0); // paused
body.extend_from_slice(&0u32.to_le_bytes()); // spellId
assert_eq!(body.len(), 21, "the server's body is 4+4+4+4+1+4 bytes");
assert_eq!(
read_start_mirror_timer(&mut &body[..]).unwrap(),
MirrorTimerStart {
kind: 1,
remaining_ms: 45_000,
duration_ms: 60_000,
scale: -1,
paused: false,
spell_id: 0,
}
);
}
/// The refill direction: surfacing re-sends the same timer with `scale = +10`, frozen flag
/// clear, and a water-breathing aura's id when one owns it. `scale` must survive as a
/// *signed* field — a `u32` read would still pass on `+10` and silently break on `-1`.
#[test]
fn start_carries_a_signed_scale_and_the_owning_spell() {
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(&30_000u32.to_le_bytes());
body.extend_from_slice(&60_000u32.to_le_bytes());
body.extend_from_slice(&10i32.to_le_bytes());
body.push(1);
body.extend_from_slice(&5697u32.to_le_bytes()); // Water Breathing
let start = read_start_mirror_timer(&mut &body[..]).unwrap();
assert_eq!(
start.scale, 10,
"refilling ten times faster than it drained"
);
assert!(start.paused);
assert_eq!(start.spell_id, 5697);
}
/// The three client timer types, and the fact that nothing else maps — the server's
/// `NUM_CLIENT_TIMERS` gate keeps its internal `ENVIRONMENTAL` (3) off the wire entirely.
#[test]
fn only_the_three_client_timer_types_map() {
assert_eq!(
MirrorTimerKind::from_wire(0),
Some(MirrorTimerKind::Fatigue)
);
assert_eq!(MirrorTimerKind::from_wire(1), Some(MirrorTimerKind::Breath));
assert_eq!(
MirrorTimerKind::from_wire(2),
Some(MirrorTimerKind::FeignDeath)
);
assert_eq!(
MirrorTimerKind::from_wire(3),
None,
"ENVIRONMENTAL: server-only"
);
assert_eq!(MirrorTimerKind::from_wire(u32::MAX), None);
for kind in [
MirrorTimerKind::Fatigue,
MirrorTimerKind::Breath,
MirrorTimerKind::FeignDeath,
] {
assert_eq!(MirrorTimerKind::from_wire(kind.to_wire()), Some(kind));
}
}
/// `SMSG_PAUSE_MIRROR_TIMER`: type then one flag byte.
#[test]
fn pause_is_a_type_and_a_flag_byte() {
let mut body = 0u32.to_le_bytes().to_vec();
body.push(1);
assert_eq!(read_pause_mirror_timer(&mut &body[..]).unwrap(), (0, true));
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0);
assert_eq!(read_pause_mirror_timer(&mut &body[..]).unwrap(), (1, false));
}
/// `SMSG_STOP_MIRROR_TIMER`: the bare type word.
#[test]
fn stop_is_the_bare_type_word() {
assert_eq!(
read_stop_mirror_timer(&mut &1u32.to_le_bytes()[..]).unwrap(),
1
);
}
/// A truncated body is an error, never a silently zeroed field — the `paused` byte and the
/// trailing `spellId` are the two the server appends last and the two a sloppy reader drops.
#[test]
fn a_truncated_start_body_is_an_error() {
let mut full = Vec::new();
full.extend_from_slice(&1u32.to_le_bytes());
full.extend_from_slice(&45_000u32.to_le_bytes());
full.extend_from_slice(&60_000u32.to_le_bytes());
full.extend_from_slice(&(-1i32).to_le_bytes());
full.push(0);
full.extend_from_slice(&0u32.to_le_bytes());
for cut in [12, 16, 17, 20] {
assert!(
read_start_mirror_timer(&mut &full[..cut]).is_err(),
"a {cut}-byte body must not decode"
);
}
assert!(read_start_mirror_timer(&mut &full[..]).is_ok());
}
}
@@ -31,6 +31,7 @@ mod group;
mod items;
mod loot;
mod mail;
mod mirror_timer;
mod monster_move;
mod movement;
pub mod opcode;
@@ -118,6 +119,10 @@ pub use mail::{
mail_mark_as_read, mail_message_type, mail_return_to_sender, mail_take_item, mail_take_money,
send_mail, MailAttachment, MailListEntry,
};
pub use mirror_timer::{
read_pause_mirror_timer, read_start_mirror_timer, read_stop_mirror_timer, MirrorTimerKind,
MirrorTimerStart,
};
pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, TransportPose};
pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
@@ -121,7 +121,7 @@ impl SpeedKind {
///
/// | mode | bit | reference effect | granted by |
/// |---|---|---|---|
/// | [`Root`](Self::Root) | `0x0000_1000` | translation dies; the fall stops. **Turning stays live** — `StartTurn 0x7c6c50` has no root gate. | root/stun/death |
/// | [`Root`](Self::Root) | `0x0000_1000` | translation dies; the fall stops. **Turning stays live**, by the input tick's authored allow-list (`0x618054`). | root/stun/death |
/// | [`WaterWalk`](Self::WaterWalk) | `0x1000_0000` | the liquid surface becomes walkable ground | `SPELL_AURA_WATER_WALK` (104) |
/// | [`FeatherFall`](Self::FeatherFall) | `0x2000_0000` | terminal fall velocity drops to 7 yd/s from 60.148 (`0x7c5d20`) | `SPELL_AURA_FEATHER_FALL` (105) |
/// | [`Hover`](Self::Hover) | `0x4000_0000` | ground contact rises by 1.0 yd (walk resolver `0x6367b0`) | `SPELL_AURA_HOVER` (106) |
@@ -679,6 +679,18 @@ pub const CMSG_DUEL_CANCELLED: u16 = 0x016D; // 365
/// opcode's high number: `0x4d474a` registers a handler for it alongside the 0x167 block.
pub const SMSG_DUEL_COUNTDOWN: u16 = 0x02B7; // 695
// The mirror timers — breath / fatigue / feign-death (decision 0874; VERIFIED vmangos
// `Opcodes_1_12_1.h:474-476` + `Server/Packets/Misc.{h,cpp}`). Server-authoritative countdowns the
// client only mirrors and integrates; the family has no CMSG and no separate "update" opcode — a
// running timer that changes anything is re-sent as a whole START. Bodies in
// [`super::mirror_timer`].
pub const SMSG_START_MIRROR_TIMER: u16 = 0x01D9; // 473
/// Never sent by vmangos, on purpose (`Player::SendMirrorTimers` substitutes a full START and says
/// why in the source): the shipped 1.12 `MirrorTimer.lua` handler reads the same `arg1` as both a
/// timer name and a number, so a real pause packet errors out in the reference UI. Decoded anyway.
pub const SMSG_PAUSE_MIRROR_TIMER: u16 = 0x01DA; // 474
pub const SMSG_STOP_MIRROR_TIMER: u16 = 0x01DB; // 475
// The mail arc (decision 0544; VERIFIED vmangos `Opcodes_1_12_1.h` + `Handlers/MailHandler.cpp`).
// There is no `SMSG_SHOW_MAILBOX` on 5875 — the mailbox window opens entirely client-side; every
// CMSG below carries the mailbox guid at its head, and the server independently re-validates the
+21 -5
View File
@@ -9,11 +9,12 @@ use super::{
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry,
InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MoveMode, Object, PartyMemberStatsInfo,
PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption,
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellCooldown,
SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask,
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object,
PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody,
SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
TransportPose, VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -875,6 +876,18 @@ pub enum ServerPacket {
DuelCountdown {
seconds: u32,
},
/// `SMSG_START_MIRROR_TIMER` — start or wholly re-state one mirror timer (the breath /
/// fatigue bars, decision 0874). The family has no update opcode: every change re-sends this.
MirrorTimerStart(MirrorTimerStart),
/// `SMSG_PAUSE_MIRROR_TIMER` — freeze/unfreeze a running timer. vmangos never sends it.
MirrorTimerPause {
kind: u32,
paused: bool,
},
/// `SMSG_STOP_MIRROR_TIMER` — that timer is over; hide its bar.
MirrorTimerStop {
kind: u32,
},
/// `SMSG_FRIEND_LIST` — the whole friend list, guids + presence (decision 0668). Pushed
/// unasked at login and on every `CMSG_FRIEND_LIST`; always complete, never a delta.
FriendList {
@@ -1178,6 +1191,9 @@ impl ServerPacket {
ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(),
ServerPacket::DuelWinner { .. } => "SMSG_DUEL_WINNER".into(),
ServerPacket::DuelCountdown { .. } => "SMSG_DUEL_COUNTDOWN".into(),
ServerPacket::MirrorTimerStart(..) => "SMSG_START_MIRROR_TIMER".into(),
ServerPacket::MirrorTimerPause { .. } => "SMSG_PAUSE_MIRROR_TIMER".into(),
ServerPacket::MirrorTimerStop { .. } => "SMSG_STOP_MIRROR_TIMER".into(),
ServerPacket::FriendList { .. } => "SMSG_FRIEND_LIST".into(),
ServerPacket::IgnoreList { .. } => "SMSG_IGNORE_LIST".into(),
ServerPacket::FriendStatus(..) => "SMSG_FRIEND_STATUS".into(),
+15 -3
View File
@@ -12,9 +12,9 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject,
gossip, group, items, loot, mail, monster_move, movement, opcode, progression, quest, social,
spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, progression,
quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state,
Character, CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -883,6 +883,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_DUEL_COUNTDOWN => ServerPacket::DuelCountdown {
seconds: duel::read_duel_countdown(&mut r)?,
},
// The mirror timers (decision 0874; bodies in `mirror_timer`). START carries the timer's
// whole state and is re-sent on every change — there is no update opcode in the family.
opcode::SMSG_START_MIRROR_TIMER => {
ServerPacket::MirrorTimerStart(mirror_timer::read_start_mirror_timer(&mut r)?)
}
opcode::SMSG_PAUSE_MIRROR_TIMER => {
let (kind, paused) = mirror_timer::read_pause_mirror_timer(&mut r)?;
ServerPacket::MirrorTimerPause { kind, paused }
}
opcode::SMSG_STOP_MIRROR_TIMER => ServerPacket::MirrorTimerStop {
kind: mirror_timer::read_stop_mirror_timer(&mut r)?,
},
// The social family (decision 0668; bodies in `social`). Both list packets are
// replace-everything snapshots; the status packet is one result about one player.
opcode::SMSG_FRIEND_LIST => ServerPacket::FriendList {
@@ -368,6 +368,12 @@ const FIELD_PLAYER_FLAGS: u16 = 190;
// three reads `UnitReaction 0x6061e0`'s duel leg makes (decision 0633).
const FIELD_PLAYER_DUEL_ARBITER: u16 = 188;
const FIELD_PLAYER_DUEL_TEAM: u16 = 196;
// PLAYER_FIELD_COMBO_TARGET = UNIT_END(188) + 0x20E (GUID, PRIVATE) — VERIFIED vmangos
// `UpdateFields_1_12_1.h:251`, and independently by the binary: `GetComboPoints 0x51a190` reads the
// pair at `[player+0xe68]+0x838/+0x83c`, and 0x838/4 + 188 = 714 (decision 0875). The same player-
// block base that puts the combo-point byte at `+0x1029` (0x1029/4 + 188 = 1222) — two offsets off
// one pointer landing on two independently-known field indices is what pins the base itself.
const FIELD_PLAYER_FIELD_COMBO_TARGET: u16 = 714;
const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos `PLAYER_FIELD_BYTES`
// (distinct from the appearance `PLAYER_BYTES`(193)):
// flags / comboPoints / actionBars / HIGHEST_HONOR_RANK
@@ -356,14 +356,30 @@ impl ObjectFields {
/// the same dword whose byte 3 is [`Self::player_honor_rank`]'s byte-verified `+0x102b`, off
/// the player-block base the duel-arbiter comment pins at `[player+0xe68]`.
///
/// **Not rogue/druid-only.** vmangos awards one here when a *warrior's* victim **dodges**
/// (`Unit.cpp` `PROC_EX_DODGE` → `AddComboPoints(target, 1)` plus a 4 s `REACTIVE_OVERPOWER`
/// timer whose expiry `ClearComboPoints`es it) — the server's chosen marker for "Overpower is
/// up", and the only way that window reaches the client. `None` before the field streams.
/// **The byte is not rogue/druid-only — the *display* is** (decision 0875). vmangos writes one
/// here when a *warrior's* victim **dodges** (`Unit.cpp` `PROC_EX_DODGE` →
/// `AddComboPoints(target, 1)` plus a 4 s `REACTIVE_OVERPOWER` timer whose expiry
/// `ClearComboPoints`es it) — the server's chosen marker for "Overpower is up", and the only
/// way that window reaches the client. Leg 5 reads this byte with no class test, which is what
/// greys Overpower; the Lua `GetComboPoints 0x51a190` reads the *same* byte behind a
/// rogue-or-druid class gate, which is why a warrior never sees a combo dot light up for it.
/// `None` before the field streams.
pub fn player_combo_points(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_FIELD_BYTES)
.map(|b| ((b >> 8) & 0xff) as u8)
}
/// `PLAYER_FIELD_COMBO_TARGET` (field 714, GUID) — the unit the banked
/// [`Self::player_combo_points`] sit on, written by `Player::SetComboPoints` in the same breath
/// as the byte and zeroed with it. PRIVATE, so it only ever streams for our own player.
///
/// Combo points are **per target**, and the client enforces that in the *display*, not on the
/// wire: `GetComboPoints 0x51a190` reads this pair at `[player+0xe68]+0x838/+0x83c`, compares
/// it against the current-target GUID global (`0xb4e2d8/dc`) and pushes `0` on a mismatch
/// (decision 0875) — so re-targeting empties the dots without the count moving, and selecting
/// the original unit again refills them. `0` when nothing is banked.
pub fn player_combo_target(&self) -> u64 {
self.get_guid(FIELD_PLAYER_FIELD_COMBO_TARGET).unwrap_or(0)
}
/// `PLAYER_FIELD_BYTES` byte 3 — the player's **highest lifetime honor rank** (vmangos
/// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's
/// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the
@@ -45,6 +45,11 @@ fn combat_stat_indices_chain_to_the_tested_anchors() {
assert_eq!(FIELD_PLAYER_AMMO_ID + 3, FIELD_PLAYER_BUYBACK_PRICE_1);
assert_eq!(FIELD_PLAYER_FIELD_BYTES, UNIT_END + 0x40A);
assert_eq!(FIELD_PLAYER_FIELD_BYTES + 1, FIELD_PLAYER_AMMO_ID);
// The combo-target GUID is the two dwords immediately BEFORE the live-tested XP pair, so it
// needs no anchor of its own. The binary agrees from the other side: `GetComboPoints 0x51a190`
// reads it at `[player+0xe68]+0x838`, and 0x838/4 = 0x20E (decision 0875).
assert_eq!(FIELD_PLAYER_FIELD_COMBO_TARGET, UNIT_END + 0x20E);
assert_eq!(FIELD_PLAYER_FIELD_COMBO_TARGET + 2, FIELD_PLAYER_XP);
// The skill array: one past the tested XP pair, 384 dwords ending at CHARACTER_POINTS1.
assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, FIELD_PLAYER_NEXT_LEVEL_XP + 1);
assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, UNIT_END + 0x212);
@@ -220,6 +225,17 @@ fn player_field_bytes_splits_into_combo_points_and_honor_rank() {
assert_eq!(ObjectFields::default().player_combo_points(), None);
}
/// `PLAYER_FIELD_COMBO_TARGET` reads as one GUID out of its two dwords — the unit the points are
/// banked on, which the display gate compares against the current target (decision 0875).
#[test]
fn player_combo_target_reads_the_guid_pair() {
let f = ObjectFields::from_pairs(&[(714, 0x1234_5678), (715, 0xF000_0001)]);
assert_eq!(f.player_combo_target(), 0xF000_0001_1234_5678);
// Nothing banked reads as 0 — the same value the current-target global holds with no target,
// which is exactly why the binary's plain equality compare needs no null special case.
assert_eq!(ObjectFields::default().player_combo_target(), 0);
}
/// The four aura arrays tile exactly, and the block lands on `BASEATTACKTIME` — an index this
/// file already chain-locks to two live-verified anchors. So this pins all four aura indices
/// without a new anchor of its own: 48 spell-id dwords, then 48 nibbles (6 dwords), then 48
+15
View File
@@ -572,6 +572,19 @@ pub(crate) struct Model {
pub(crate) player_xp: u32,
pub(crate) player_next_level_xp: u32,
/// The player's banked combo points and the unit they sit on — the raw `PLAYER_FIELD_BYTES`
/// byte 1 and `PLAYER_FIELD_COMBO_TARGET` GUID, pushed together each frame either moves
/// ([`UiScript::set_combo_points`]). Player-global PRIVATE fields, like `money`.
///
/// **Raw wire values, deliberately ungated.** The server banks a point here for a *warrior*
/// too (the Overpower window), and the usable walk's leg 5 consumes exactly that. The two
/// gates the real `GetComboPoints 0x51a190` applies — rogue-or-druid only, and the banked
/// target must be the CURRENT target — live in the binding, where the binary puts them
/// (decision 0875). Read [`Self::combo_points`] directly and you are reading the wire, not
/// what the reference UI can see.
pub(crate) combo_points: u8,
pub(crate) combo_target: u64,
/// The paper doll's combat-stats snapshot (`None` until the app's feed lands), the
/// equipment/ammo slot views, and the model pane's persistent bake yaw — the character-window
/// seam ([`char_stats`], decision 0208 §3). All player-only data: the backing descriptor
@@ -824,6 +837,8 @@ impl Model {
net_latency_ms: 0,
player_xp: 0,
player_next_level_xp: 0,
combo_points: 0,
combo_target: 0,
player_combat_stats: None,
inventory_slots: Default::default(),
inventory_alerts: [0; 12],
+27
View File
@@ -100,6 +100,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// GetName() → this region's global name, or nil when it was declared anonymously — the pair
// the frame side already answers. Real FrameXML round-trips a region through its name wherever
// it can't hold a reference to itself: `ComboFrame.lua`'s shine chain hands `frame:GetName()`
// to a fade `finishedFunc` and `getglobal`s it back ("hack since a frame can't have a
// reference to itself in it" — its own comment).
//
// Resolved by scanning the region-name registry rather than storing the name a second time on
// the region: that registry is the single authority for region names (the widget arena
// deliberately holds none), and a mirrored copy is one more thing to drift. The scan is linear
// in NAMED regions, and this is a human-rate call.
m.set(
"GetName",
lua.create_function(|lua, this: Table| {
let id = decode_id(&this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = model
.region_names
.iter()
.find(|&(_, &v)| v == id)
.map(|(k, _)| k.clone());
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
// ref kit hides tab slices, cooldown swipes, money coins…). A hidden region draws nothing;
// IsVisible additionally requires the owner frame's effective visibility, mirroring the
@@ -9,6 +9,15 @@ use super::{
classification_word, grey_band, level_reads_unknown, pick_unit_token, power_token, with_unit,
};
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
/// the class byte `[[player+0x110]+0x79]` against. That byte is `UNIT_FIELD_BYTES_0` byte 1, the
/// same value [`PlayerReqState::class_id`](super::super::PlayerReqState) carries; `UnitClass`'s own
/// binding (`0x518350`) reads it through the identical `[obj+0x110]+0x79` chain, which is what
/// identifies it. The *names* are the conventional vanilla ids — the client's class table is
/// heap-built, so nothing in the file maps 4→Rogue by itself (decision 0875).
const CLASS_ROGUE: u32 = 4;
const CLASS_DRUID: u32 = 11;
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
/// surface).
@@ -492,6 +501,45 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetComboPoints() → the player's banked combo points, 0..5 (a player-level global, not a unit
// token — the 1.12 binding takes no arguments). `ComboFrame` shows/hides on it, and the
// combat-text COMBO_POINTS arm reads it.
//
// TWO gates before the byte, transcribed from `0x51a190` (§5 byte-read, decision 0875) — the
// reference `ComboFrame.lua` carries no class check at all, so BOTH of them live here or
// nowhere:
//
// 51a1c3 mov eax,[esi+0x110]; mov al,[eax+0x79] ; the class byte
// 51a1cc cmp al,4 / cmp al,0xb → jne push 0.0 ; ROGUE or DRUID, nothing else
// 51a1f3 edx=[esi+0xe68]; eax=[edx+0x838] …0x83c ; PLAYER_FIELD_COMBO_TARGET
// 51a205 cmp against [0xb4e2d8]/[0xb4e2dc] ; == the CURRENT target, or push 0.0
// 51a234 mov al,[ecx+0x1029] ; only now, PLAYER_FIELD_BYTES byte 1
//
// The class gate is why a *warrior* never sees a dot even though the server really does bank a
// point for them on a victim's dodge: that byte reaches the usable walk's leg 5 (which has no
// class test, and is what greys Overpower — 0869) but stops here, before any UI can see it.
// The target gate is why combo points read as "per target": re-targeting empties the dots
// without the count moving, and selecting the banked unit again refills them.
//
// Both comparisons are the binary's own plain equality — no null special case. With nothing
// banked and no target both GUIDs are 0, which passes the target gate and reads a byte that is
// 0 anyway; the server writes and clears the pair together.
g.set(
"GetComboPoints",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let class = model.player_req.class_id;
if class != CLASS_ROGUE && class != CLASS_DRUID {
return Ok(0_i64);
}
let target = model.units.get("target").map_or(0, |u| u.guid);
if model.combo_target != target {
return Ok(0_i64);
}
Ok(i64::from(model.combo_points))
})?,
)?;
// TargetUnit(unit) — select a unit by token (the reference's `TargetUnit` Lua shim → SetSelection;
// the caller here is `PlayerFrame_OnClick`'s left-click branch → `TargetUnit("player")`, and the
// TARGETSELF binding). Queues the raw token; the app resolves it to a streamed entity and commits
+14
View File
@@ -276,6 +276,20 @@ impl super::UiScript {
model.player_next_level_xp = next_level_xp;
}
/// Push the player's banked combo points (`PLAYER_FIELD_BYTES` byte 1) and the unit they sit
/// on (`PLAYER_FIELD_COMBO_TARGET`) — the pair `Player::SetComboPoints` writes together, taken
/// together so they can never be read half-updated. Player-level PRIVATE fields, same shape as
/// [`Self::set_money`]; the app calls this each frame either moves, including the drop back to
/// zero (decisions 0869, 0875).
///
/// **Raw wire values** — `GetComboPoints`'s class and current-target gates live in the binding,
/// where the binary puts them.
pub fn set_combo_points(&mut self, points: u8, target: u64) {
let mut model = self.model_mut();
model.combo_points = points;
model.combo_target = target;
}
/// Drain the unit tokens `TargetUnit` queued since the last call — the app resolves each to a
/// streamed entity and commits the selection (the reference's `TargetUnit` → SetSelection path).
pub fn take_target_requests(&mut self) -> Vec<String> {
+6 -4
View File
@@ -15,8 +15,9 @@
the precedent note), so the 20 instances inherit the CombatTextFont Font object and
carry the template's anchor directly.
- UNIT_MANA → UNIT_POWER_UPDATE + the Era Unit* API (our event surface, decision 0068);
same low-mana law. PLAYER_COMBO_POINTS is not registered (no combo wire yet) — the
handler arm stays for when it lands.
same low-mana law. PLAYER_COMBO_POINTS rides the combo wire (0869); its arm reads
`GetComboPoints()`, so it is rogue/druid-only and per-target for free — the binding
gates, not this file (0875).
- Lua 5.0 `for k,v in t` iteration becomes explicit index loops (5.1 VM), backwards
where the body removes.
The wire → messageType mapping feeding COMBAT_TEXT_UPDATE lives app-side
@@ -197,8 +198,7 @@
elseif event == "PLAYER_REGEN_ENABLED" then
messageType = "LEAVING_COMBAT"
elseif event == "PLAYER_COMBO_POINTS" then
-- Not registered yet (no combo wire) — the arm stays for when it lands.
local comboPoints = GetComboPoints and GetComboPoints() or 0
local comboPoints = GetComboPoints()
if comboPoints > 0 then
messageType = "COMBO_POINTS"
data = comboPoints
@@ -460,6 +460,7 @@
CombatText:UnregisterEvent("COMBAT_TEXT_UPDATE")
CombatText:UnregisterEvent("UNIT_HEALTH")
CombatText:UnregisterEvent("UNIT_POWER_UPDATE")
CombatText:UnregisterEvent("PLAYER_COMBO_POINTS")
CombatText:UnregisterEvent("PLAYER_REGEN_DISABLED")
CombatText:UnregisterEvent("PLAYER_REGEN_ENABLED")
return
@@ -467,6 +468,7 @@
CombatText:RegisterEvent("COMBAT_TEXT_UPDATE")
CombatText:RegisterEvent("UNIT_HEALTH")
CombatText:RegisterEvent("UNIT_POWER_UPDATE")
CombatText:RegisterEvent("PLAYER_COMBO_POINTS")
CombatText:RegisterEvent("PLAYER_REGEN_DISABLED")
CombatText:RegisterEvent("PLAYER_REGEN_ENABLED")
end
+181
View File
@@ -0,0 +1,181 @@
<Ui>
<!--
The combo-point dots (decision 0869) — the reference `FrameXML/ComboFrame.xml` +
`ComboFrame.lua` transcribed, geometry QUOTED verbatim (offsets, sizes, TexCoords and the
five fade constants are the reference's own), renamed onto our frames and driven by our
`PLAYER_COMBO_POINTS` feed.
ONE `Interface\ComboFrame\ComboPoint` sheet holds all three layers, split by TexCoords: the
dim socket (0 .. 0.375), the lit highlight (0.375 .. 0.5625) and the additive shine
(0.5625 .. 1). A point that comes up fades its highlight in, and the highlight's completion
hands off to the shine's fade-in → fade-out — the reference's two-hop `finishedFunc` chain,
which our `UIFrameFade` (UiPanels.xml) supports verbatim.
NO CLASS TEST HERE, and none is missing: this file gates on `GetComboPoints() > 0` and
nothing else because the reference does, and `0x51a190` carries both real gates itself —
rogue-or-druid (`cmp al,4 / cmp al,0xb`), and the points must be banked on the CURRENT
target. So a *warrior* never lights a dot even though the server really does bank them a
point on a victim's dodge — that byte greys Overpower through the usable walk's leg 5 and
goes no further (§5 byte-read, decision 0875). Re-targeting empties these dots with the
count untouched, which is exactly why the reference registers PLAYER_TARGET_CHANGED.
The five dots march down-left, each anchored TOP-to-BOTTOM of the one before with a small
rightward drift — the reference's diagonal, hugging the target frame's ring.
-->
<Frame name="BenillaComboPointTemplate" virtual="true">
<Size><AbsDimension x="12" y="12"/></Size>
<Layers>
<!-- The empty socket (ref l.8-19). -->
<Layer level="BACKGROUND">
<Texture file="Interface\ComboFrame\ComboPoint">
<Size><AbsDimension x="12" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT"/>
</Anchors>
<TexCoords left="0" right="0.375" top="0" bottom="1"/>
</Texture>
</Layer>
<!-- The lit point, faded in as the point is earned (ref l.20-34). -->
<Layer level="ARTWORK">
<Texture name="$parentHighlight" file="Interface\ComboFrame\ComboPoint">
<Size><AbsDimension x="8" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.375" right="0.5625" top="0" bottom="1"/>
</Texture>
</Layer>
<!-- The additive flare, chained off the highlight's fade (ref l.35-48). -->
<Layer level="OVERLAY">
<Texture name="$parentShine" file="Interface\ComboFrame\ComboPoint" alphaMode="ADD">
<Size><AbsDimension x="14" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="4"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.5625" right="1" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
</Frame>
<Frame name="BenillaComboFrame" frameStrata="MEDIUM" toplevel="true" parent="UIParent" hidden="true" alpha="0">
<Size><AbsDimension x="256" y="32"/></Size>
<!-- ref l.56-62: TOPRIGHT of the target frame, (-44,-9). -->
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="BenillaTargetFrame">
<Offset><AbsDimension x="-44" y="-9"/></Offset>
</Anchor>
</Anchors>
<Frames>
<Frame name="BenillaComboPoint1" inherits="BenillaComboPointTemplate">
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="BenillaComboPoint2" inherits="BenillaComboPointTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="BenillaComboPoint1" relativePoint="BOTTOM">
<Offset><AbsDimension x="7" y="4"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="BenillaComboPoint3" inherits="BenillaComboPointTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="BenillaComboPoint2" relativePoint="BOTTOM">
<Offset><AbsDimension x="5" y="2"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="BenillaComboPoint4" inherits="BenillaComboPointTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="BenillaComboPoint3" relativePoint="BOTTOM">
<Offset><AbsDimension x="2" y="1"/></Offset>
</Anchor>
</Anchors>
</Frame>
<!-- The fifth is the odd one out: the reference sizes it 15×18 (ref l.101-104). -->
<Frame name="BenillaComboPoint5" inherits="BenillaComboPointTemplate">
<Size><AbsDimension x="15" y="18"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="BenillaComboPoint4" relativePoint="BOTTOM">
<Offset><AbsDimension x="0" y="1"/></Offset>
</Anchor>
</Anchors>
</Frame>
</Frames>
<Scripts>
<OnLoad>
this:RegisterEvent("PLAYER_TARGET_CHANGED");
this:RegisterEvent("PLAYER_COMBO_POINTS");
BenillaComboPoint1Highlight:SetAlpha(0);
BenillaComboPoint1Shine:SetAlpha(0);
</OnLoad>
<OnEvent>
BenillaComboPointsFrame_OnEvent();
</OnEvent>
</Scripts>
</Frame>
<Script>
<![CDATA[
-- ref ComboFrame.lua l.1-6, verbatim.
MAX_COMBO_POINTS = 5;
COMBOFRAME_FADE_IN = 0.3;
COMBOFRAME_FADE_OUT = 0.5;
COMBOFRAME_HIGHLIGHT_FADE_IN = 0.4;
COMBOFRAME_SHINE_FADE_IN = 0.3;
COMBOFRAME_SHINE_FADE_OUT = 0.4;
COMBO_FRAME_LAST_NUM_POINTS = 0;
-- ref ComboFrame.lua `ComboPointsFrame_OnEvent`. Only points ABOVE the last count animate
-- (`i > COMBO_FRAME_LAST_NUM_POINTS`) — a repaint at the same count must not re-flare every
-- dot, which is why the last count is remembered across events rather than recomputed.
function BenillaComboPointsFrame_OnEvent()
local comboPoints = GetComboPoints();
if comboPoints > 0 then
if not BenillaComboFrame:IsVisible() then
BenillaComboFrame:Show();
UIFrameFadeIn(BenillaComboFrame, COMBOFRAME_FADE_IN);
end
for i = 1, MAX_COMBO_POINTS do
local comboPointHighlight = getglobal("BenillaComboPoint"..i.."Highlight");
local comboPointShine = getglobal("BenillaComboPoint"..i.."Shine");
if i <= comboPoints then
if i > COMBO_FRAME_LAST_NUM_POINTS then
local fadeInfo = {};
fadeInfo.mode = "IN";
fadeInfo.timeToFade = COMBOFRAME_HIGHLIGHT_FADE_IN;
fadeInfo.finishedFunc = BenillaComboPointShineFadeIn;
fadeInfo.finishedArg1 = comboPointShine;
UIFrameFade(comboPointHighlight, fadeInfo);
end
else
comboPointHighlight:SetAlpha(0);
comboPointShine:SetAlpha(0);
end
end
else
BenillaComboPoint1Highlight:SetAlpha(0);
BenillaComboPoint1Shine:SetAlpha(0);
BenillaComboFrame:Hide();
end
COMBO_FRAME_LAST_NUM_POINTS = comboPoints;
end
function BenillaComboPointShineFadeIn(frame)
local fadeInfo = {};
fadeInfo.mode = "IN";
fadeInfo.timeToFade = COMBOFRAME_SHINE_FADE_IN;
fadeInfo.finishedFunc = BenillaComboPointShineFadeOut;
fadeInfo.finishedArg1 = frame:GetName();
UIFrameFade(frame, fadeInfo);
end
-- The name round-trip is the reference's own workaround ("hack since a frame can't have a
-- reference to itself in it") — kept, so the chain reads as its source does.
function BenillaComboPointShineFadeOut(frameName)
UIFrameFadeOut(getglobal(frameName), COMBOFRAME_SHINE_FADE_OUT);
end
]]>
</Script>
</Ui>
+232
View File
@@ -0,0 +1,232 @@
<Ui>
<!--
The mirror timers - the 1.12 MirrorTimer1/2/3, transcribed from the extracted reference
(Interface\FrameXML\MirrorTimer.xml/.lua, mpqx; decision 0874). Geometry, textures, colors
and the countdown integration are the reference's own.
"Mirror" is the client's word for a timer whose authority is the SERVER: the client never
computes breath or fatigue. The server ships a value + a signed rate and the frame
integrates `value + scale * elapsed` between packets - that OnUpdate line IS the whole of
the client-side motion. crate::ui_mirror fires the three events off the wire:
MIRROR_TIMER_START (name, remaining_ms, duration_ms, scale, paused, label) /
MIRROR_TIMER_PAUSE (name, paused) / MIRROR_TIMER_STOP (name).
arg1 is always the timer's NAME - "EXHAUSTION" / "BREATH" / "FEIGNDEATH" - the same key
MirrorTimerColors is indexed by, and the same key the client builds `<NAME>_LABEL` from to
look up the bar's caption ("Fatigue" / "Breath"; there is no FEIGNDEATH_LABEL in the 1.12
GlobalStrings, so that one shows an empty caption, exactly as the reference does).
Departures from the reference, all three deliberate:
1. THE START DRIVER. The reference registers MIRROR_TIMER_START on UIParent itself and
calls MirrorTimer_Show from UIParent_OnEvent (ref UIParent.lua l.97 + l.374-375). Our
UIParent.xml is script-only - it has no frame of its own to register events on - so the
hidden MirrorTimerDriver below hosts that one registration and makes the identical call.
2. THE FREE-DIALOG LOOP BOUND. The reference's second loop in MirrorTimer_Show iterates to
STATICPOPUP_NUMDIALOGS, a copy-paste from StaticPopup.lua (its first loop, over the same
frames, correctly uses MIRRORTIMER_NUMTIMERS). With 3 frames and 3 client timer types
the bug is latent - but it would index a nil MirrorTimer4. Corrected to the constant
that names these frames.
3. THE PAUSE ARG. The reference's MIRROR_TIMER_PAUSE branch reads the same arg1 as both a
timer name (`arg1 ~= this.timer`, above) and a number (`arg1 > 0`), so a real pause
packet errors out in the reference UI - which is why vmangos refuses to send one and
substitutes a full START instead (Player::SendMirrorTimers says so in the source). We
read the flag from arg2, which is what both branches plainly intend.
MirrorTimerColors carries a "DEATH" entry the reference never uses (no timer type maps to
it); kept verbatim.
POSITION: TOP, y=-96 - the reference's own anchor, under the top-center of the screen, with
timers 2 and 3 stacking downward from it.
-->
<Script><![CDATA[
MIRRORTIMER_NUMTIMERS = 3
MirrorTimerColors = {}
MirrorTimerColors["EXHAUSTION"] = { r = 1.00, g = 0.90, b = 0.00 }
MirrorTimerColors["BREATH"] = { r = 0.00, g = 0.50, b = 1.00 }
MirrorTimerColors["DEATH"] = { r = 1.00, g = 0.70, b = 0.00 }
MirrorTimerColors["FEIGNDEATH"] = { r = 1.00, g = 0.70, b = 0.00 }
function MirrorTimer_Show(timer, value, maxvalue, scale, paused, label)
-- Pick a free dialog to use
local dialog = nil
if not dialog then
-- Find an open dialog of the requested type
for index = 1, MIRRORTIMER_NUMTIMERS, 1 do
local frame = getglobal("MirrorTimer"..index)
if frame:IsVisible() and (frame.timer == timer) then
dialog = frame
break
end
end
end
if not dialog then
-- Find a free dialog (ref: STATICPOPUP_NUMDIALOGS - see departure 2)
for index = 1, MIRRORTIMER_NUMTIMERS, 1 do
local frame = getglobal("MirrorTimer"..index)
if not frame:IsVisible() then
dialog = frame
break
end
end
end
if not dialog then
return nil
end
dialog.timer = timer
dialog.value = (value / 1000)
dialog.scale = scale
if paused > 0 then
dialog.paused = 1
else
dialog.paused = nil
end
-- Set the text of the dialog
local text = getglobal(dialog:GetName().."Text")
text:SetText(label)
-- Set the status bar of the dialog
local statusbar = getglobal(dialog:GetName().."StatusBar")
local color = MirrorTimerColors[timer]
statusbar:SetMinMaxValues(0, (maxvalue / 1000))
statusbar:SetValue(dialog.value)
statusbar:SetStatusBarColor(color.r, color.g, color.b)
dialog:Show()
return dialog
end
function MirrorTimerFrame_OnLoad()
this:RegisterEvent("MIRROR_TIMER_PAUSE")
this:RegisterEvent("MIRROR_TIMER_STOP")
this:RegisterEvent("PLAYER_ENTERING_WORLD")
this.timer = nil
end
function MirrorTimerFrame_OnEvent()
if event == "PLAYER_ENTERING_WORLD" then
this:Hide()
this.timer = nil
end
if not this:IsShown() or (arg1 ~= this.timer) then
return
end
if event == "MIRROR_TIMER_PAUSE" then
-- ref: `if arg1 > 0` on the name it just string-compared (see departure 3)
if arg2 and arg2 > 0 then
this.paused = 1
else
this.paused = nil
end
return
end
if event == "MIRROR_TIMER_STOP" then
this:Hide()
this.timer = nil
end
end
function MirrorTimerFrame_OnUpdate(frame, elapsed)
if frame.paused then
return
end
local statusbar = getglobal(frame:GetName().."StatusBar")
frame.value = (frame.value + frame.scale * elapsed)
statusbar:SetValue(frame.value)
end
-- The ref's UIParent_OnEvent MIRROR_TIMER_START arm, rehosted (see departure 1).
function MirrorTimerDriver_OnLoad()
this:RegisterEvent("MIRROR_TIMER_START")
end
function MirrorTimerDriver_OnEvent()
MirrorTimer_Show(arg1, arg2, arg3, arg4, arg5, arg6)
end
]]></Script>
<Frame name="MirrorTimerTemplate" toplevel="true" parent="UIParent" hidden="true" virtual="true">
<Size><AbsDimension x="206" y="26"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture>
<Size><AbsDimension x="195" y="13"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="-2"/></Offset>
</Anchor>
</Anchors>
<Color r="0" g="0" b="0" a="0.5"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontHighlight">
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="0"/></Offset>
</Anchor>
</Anchors>
</FontString>
<Texture name="$parentBorder" file="Interface\CastingBar\UI-CastingBar-Border">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="25"/></Offset>
</Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<StatusBar name="$parentStatusBar">
<Size><AbsDimension x="195" y="13"/></Size>
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="-2"/></Offset>
</Anchor>
</Anchors>
<Scripts>
<OnLoad>this:SetFrameLevel(this:GetFrameLevel() - 1)</OnLoad>
</Scripts>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
</StatusBar>
</Frames>
<Scripts>
<OnLoad>MirrorTimerFrame_OnLoad()</OnLoad>
<OnEvent>MirrorTimerFrame_OnEvent()</OnEvent>
<OnUpdate>MirrorTimerFrame_OnUpdate(this, arg1)</OnUpdate>
</Scripts>
</Frame>
<Frame name="MirrorTimer1" inherits="MirrorTimerTemplate">
<Anchors>
<Anchor point="TOP">
<Offset><AbsDimension x="0" y="-96"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="MirrorTimer2" inherits="MirrorTimerTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="MirrorTimer1" relativePoint="BOTTOM"/>
</Anchors>
</Frame>
<Frame name="MirrorTimer3" inherits="MirrorTimerTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="MirrorTimer2" relativePoint="BOTTOM"/>
</Anchors>
</Frame>
<!-- The START driver (departure 1): no art, no size - one event registration. -->
<Frame name="MirrorTimerDriver" parent="UIParent">
<Scripts>
<OnLoad>MirrorTimerDriver_OnLoad()</OnLoad>
<OnEvent>MirrorTimerDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
+5 -1
View File
@@ -483,7 +483,11 @@ pub(crate) fn face_billboards(
// screenshot cannot tell them apart; this line does (it split Arcane
// Intellect's "missing" stars into scale ✓ / place ✓ / alpha ×0.3 — the
// faithful stealth-aura compose, not a defect).
if crate::dbg_trace::enabled() && elapsed_ms % 512 < 20 {
// `enabled_for`, not `enabled`: this is by far the busiest tag in the file
// (thousands of lines a second in a populated scene, each an unbuffered write
// under the shared mutex), so it is the one most worth dropping cheaply when the
// run is asking a movement question — `WOW_MOVE_TRACE_TAGS`, decision 0880.
if crate::dbg_trace::enabled_for("card") && elapsed_ms % 512 < 20 {
crate::dbg_trace::line(
"card",
&format!(
+12
View File
@@ -744,6 +744,18 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// The combo-point dots at the target frame's top-right (decisions 0869/0875). `demo_unit_feed`
// seeds a ROGUE with four points banked on the selected wolf for this scenario only — the demo
// player is a warrior everywhere else, and a warrior authentically lights no dot. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-combopoints`.
Scenario {
name: "ui-combopoints",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: None,
},
// The re-skinned main action bar (no window fixture — the bar loads by default under
// WOW_CAPTURE_UI=1, and `demo_unit_feed` seeds the action slots + player XP). The bar is 1024
// wide + 128px end caps, so this fixture takes a WIDER, shorter window (see main.rs's per-capture
+43 -2
View File
@@ -91,6 +91,23 @@ pub(crate) struct Wielded {
/// Sheath 89 otherwise; the slot's own byte is the *only* input).
pub(crate) main_sheath: u8,
pub(crate) off_sheath: u8,
/// The ranged item's sheath type, same pick — the ranged **draw** clip reads it
/// (`0x6118a0`/`0x611960`/`0x611a20` all run `(1 << (rng[+4] & 0x1f)) & 0x88` on the ranged
/// record). Its **stow** clip does not: `0x611b60`'s PREV==2 legs push a literal `0x59`
/// (`0x611c8c`/`0x611cd3`), so a bow is always put away with Sheath 89.
pub(crate) ranged_sheath: u8,
/// The ranged item's `InventoryType` — the byte-verified **arm** pick: `0x1a`
/// (RANGEDRIGHT: gun/crossbow/wand) and `0x19` (THROWN) go to the right arm, everything else
/// (`0x0f`, a bow) to the left (`0x611b60` @ `0x611c74`, and the same compare in both
/// drawers). `0` when nothing is in the ranged slot.
pub(crate) ranged_inv: u32,
/// Each slot's `Material` id — the **only** key the draw/stow sound pick uses
/// (`SheatheSoundLookups`, decision 0882). Not derivable from the weapon class: real 5875 data
/// has maces (subclass 4) in both metal and wood, and puts every bow, crossbow and wand in
/// wood while guns and thrown are metal. It rides the wire — `SMSG_ITEM_QUERY_SINGLE_RESPONSE`
/// for players, the `UNIT_VIRTUAL_ITEM_INFO` byte triple for creatures — so nothing here is a
/// guess. Indexed by held slot: 0 mainhand · 1 offhand · 2 ranged.
pub(crate) materials: [u8; 3],
}
/// The unit is engaged in melee auto-attack (`SMSG_ATTACKSTART` .. `ATTACKSTOP`, decision 0073):
@@ -518,9 +535,18 @@ pub(crate) struct AnimDriver {
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
/// sent to the SpineLow overlay (moving / seated / airborne-in-combat), playing *beside* [`Mode`]
/// while the base track keeps driving the legs (run / sit / jump-arc). `None` = no overlay; a
/// finished overlay releases the subtree back to the base. The full-body route never uses this —
/// it replaces the base via [`Mode::Swing`].
/// finished overlay **fades** the subtree back to the base ([`Self::overlay_fade`]) rather than
/// dropping it. The full-body route never uses this — it replaces the base via [`Mode::Swing`]
/// — until a locomotion request **transplants** that clip up here (decision 0878).
overlay: Option<Overlay>,
/// The key-bone slot's **cross-fade in flight** (decision 0878) — the client's per-bone
/// SECONDARY as an upper-body arm or release seeds it. Two producers, one curve
/// ([`select::blend_lambda`], λ decaying 1 → 0): the **fade-to-rest** that releases a finished
/// one-shot over a fixed 150 ms (`0x5fc920` → op4 `param_3 = −1`, `0x7123af`), and a **blended
/// re-arm** cross-fading a new masked clip in over the incoming clip's own blendTime
/// (`0x7125f2`). A **transplant** arm carries `blendFlag = 0` and seeds nothing — it resumes
/// the clip at its live frame with no fade at all.
overlay_fade: Option<OverlayFade>,
/// The victim **wound-flinch** in flight (decision 0111) — the client's per-bone **SECONDARY
/// blend slot**: a decaying cross-fade overlay (`λ` smoothstep `0.75 → 0` over the wound
/// clip's own span) layered over whatever else plays, then self-releasing. Deliberately a
@@ -599,6 +625,20 @@ struct Overlay {
looping: bool,
}
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878 — wow-re
/// `oneshot-lifecycle.md` §5.4). `out` is the **retiring** node, holding the outgoing pose the
/// client snapshots into the bone's secondary slot (`rep movsd +0x98 → +0xc4`); it is `None` when
/// a fresh clip fades in over the *inherited base* pose, which needs no node of its own — the base
/// is already there. `left`/`total` are the window: the fixed 150 ms of a fade-to-rest, or the
/// incoming clip's own blendTime on a blended re-arm.
#[derive(Clone, Copy)]
struct OverlayFade {
out: Option<bevy::animation::graph::AnimationNodeIndex>,
/// Seconds still to run — λ = [`select::blend_lambda`]`(left / total)`, decaying 1 → 0.
left: f32,
total: f32,
}
/// A wound-flinch decay in flight ([`AnimDriver::wound`], decision 0111): the graph node the
/// blend drives (the clip's masked [`AnimClip::upper_node`], or its full-body node when the
/// bone-selection forces bone 0 — [`select::wound_full_body`]) and the decay window's inputs.
@@ -624,6 +664,7 @@ impl Default for AnimDriver {
sheath_byte: None,
sheath_swap: None,
overlay: None,
overlay_fade: None,
wound: None,
jump_arc: false,
was_falling: false,
+306 -82
View File
@@ -25,11 +25,11 @@ use super::select::{
playback_rate, ready_anim, route_oneshot, state_emote_gait, swing_anim_main, swing_anim_off,
unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
};
use super::sheath::start_sheath_ceremony;
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim,
EmoteAnim, Engaged, MovementState, Overlay, RangedHold, SheathRequest, SheathSwapMessage,
SwingImpact, SwingMessage, SwingSlowdown, VisualSheath, Wielded, WoundAnim,
EmoteAnim, Engaged, MovementState, Overlay, OverlayFade, RangedHold, SheathRequest,
SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim,
};
mod grip;
@@ -52,6 +52,16 @@ use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
/// value as the per-arm sheath ceremony's overlay weight.
const ONESHOT_OVERLAY_WEIGHT: f32 = 8.0;
/// The key-bone **fade-to-rest** window (decision 0878 — wow-re `oneshot-lifecycle.md` §5.4): a
/// finished upper-body one-shot is never stopped. The client's per-frame advance latches the
/// completion (`0x719370`), the deferred event reaches `CGUnit::OnAnimationFinished 0x5fc920` the
/// same frame, and that calls op4 with `param_3 = -1` (`0x5fcacb`) — which snapshots the clip's
/// **held final frame** into the bone's secondary slot, seeds `+0x100 = clock + 150`,
/// `+0x104 = 1/150`, `+0x108 = 1.0`, and disarms the primary so the bone inherits bone 0 again.
/// **Fixed 150 ms** — deliberately NOT the sequence's own blendTime, which is used only on the arm
/// path.
const ONESHOT_RELEASE_FADE: f32 = 0.150;
/// The launch vertical speed (yd/s, +up) above which a new airborne arc is a **jump** (the 37/38
/// bracket) rather than a step-off fall. A jump launches at ≈7.96 up (our own controller's
/// `JUMP_SPEED`; a remote's `-zspeed - g·t` is still ≳4 after relay latency); a step-off starts
@@ -104,6 +114,144 @@ pub(crate) fn oneshot_is_live(
live_oneshot(drv, player, tr, anims, catalog).is_some()
}
/// Retire whatever holds the key-bone slot into its cross-fade ([`AnimDriver::overlay_fade`]) —
/// the client's op4 snapshot of the outgoing pose into the bone's SECONDARY (`rep movsd
/// +0x98 → +0xc4`) plus the window seed (decision 0878). `total` is the window: the incoming
/// clip's own blendTime on a blended re-arm, [`ONESHOT_RELEASE_FADE`] on a fade-to-rest. Honors
/// the client's **re-snapshot guard** (`0x7125d4` on the arm path, `0x7123a2` on the release
/// path, both against `0x7ffa24 = 0.5f`): a fade still running above λ = 0.5 is not re-seeded —
/// the older pose keeps decaying and the superseded node is simply dropped. Leaves
/// [`AnimDriver::overlay`] empty; the caller arms the incoming clip (if any) after it.
fn retire_overlay(drv: &mut AnimDriver, player: &mut AnimationPlayer, total: f32) {
let out = drv.overlay.take().map(|ov| ov.node);
if drv.overlay_fade.is_some_and(|f| fade_lambda(&f) > 0.5) {
if let Some(n) = out {
player.stop(n);
}
return;
}
if let Some(prev) = drv.overlay_fade.take().and_then(|f| f.out) {
player.stop(prev);
}
drv.overlay_fade = Some(OverlayFade {
out,
left: total,
total,
});
}
/// The blend weight λ of a key-bone cross-fade this frame — `smoothstep` over the fraction of the
/// window still to run, so it decays 1 → 0 ([`select::blend_lambda`]).
fn fade_lambda(f: &OverlayFade) -> f32 {
select::blend_lambda(if f.total > 0.0 { f.left / f.total } else { 0.0 })
}
/// The key-bone cross-fade's per-frame advance (decision 0878) — the kernel's λ decay
/// (`0x714880`–`0x714923`) and its self-release (`0x7147b9`: `+0xd0 = -1` and λ = 0 the same
/// frame). Two shapes, decided by whether an incoming clip holds the slot:
///
/// - **A release** (no incoming): the retiring node alone fades against the base, so its weight
/// must land the blended share on `λ · W/(1+W)` — the client's full-override λ, capped by the
/// standing [`ONESHOT_OVERLAY_WEIGHT`] approximation. `w = W·λ / (1 + W·(1−λ))`.
/// - **A re-arm** (incoming present): the two clips split the slot's share by λ exactly as the
/// client's `primary + (secondary − primary)·λ` does, at constant total — `W·λ` out, `W·(1−λ)`
/// in. (An incoming that arrived by *transplant* seeds no fade at all: `blendFlag = 0`.)
fn overlay_fade_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer, dt: f32) {
let Some(mut f) = drv.overlay_fade else {
return;
};
f.left = (f.left - dt).max(0.0);
let lambda = fade_lambda(&f);
let live = drv.overlay.map(|ov| ov.node);
let w = ONESHOT_OVERLAY_WEIGHT;
if let Some(a) = f.out.and_then(|n| player.animation_mut(n)) {
a.set_weight(if live.is_some() {
w * lambda
} else {
w * lambda / (1.0 + w * (1.0 - lambda))
});
}
if let Some(a) = live.and_then(|n| player.animation_mut(n)) {
a.set_weight(w * (1.0 - lambda));
}
if f.left <= 0.0 {
if let Some(n) = f.out {
player.stop(n);
}
if let Some(a) = live.and_then(|n| player.animation_mut(n)) {
a.set_weight(w);
}
drv.overlay_fade = None;
} else {
drv.overlay_fade = Some(f);
}
}
/// The **TRANSPLANT** (`0x5fe919` — wow-re `oneshot-lifecycle.md` §3a, the half
/// `anim-composition-model.md` §2 was missing; decision 0878). A base **locomotion** clip
/// requested while bone 0 still plays a live **CAST** or **COMBAT** one-shot does not replace it:
/// the client copies the bone-0 descriptor — its id, its rate, and `+0x08` the clip's **live
/// elapsed position** — onto the key-bone with `blendFlag = 0`, then hands bone 0 the request. The
/// cast therefore keeps running on the torso *at exactly the frame it was on*, with no cross-fade
/// and no restart, while the legs take the jump or the run. This is the director's "jump right
/// after a cast: the legs jump, the arms finish the cast".
///
/// A no-op when the key-bone is already armed — `0x5fe912` then jumps straight to `0x5fe930`
/// (request → bone 0, key-bone untouched), which is exactly what leaving [`AnimDriver::overlay`]
/// alone already gives us. Named simplification: the moved clip re-arms as a plain `Never` repeat,
/// dropping any unspent replay budget (`R > 1`); every cast and swing this fires for is authored
/// `(0, 0)` ⇒ `R = 1`.
fn transplant_up(
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
entity: Entity,
id: u16,
) -> bool {
if drv.overlay.is_some() || !(select::is_cast_anim(id) || select::is_combat_anim(id)) {
return false;
}
let Some(node) = tr.get_main_animation() else {
return false;
};
// The live position + rate, read off the armed VARIATION node (the play rolled one of them),
// which is also how we find the masked twin to resume on.
let Some((seek, speed)) = player
.animation(node)
.filter(|a| !a.is_finished())
.map(|a| (a.seek_time(), a.speed()))
else {
return false;
};
let Some(upper) = anims
.clips
.iter()
.find(|c| c.node == node)
.and_then(|c| c.upper_node)
else {
return false;
};
let active = player.play(upper);
active.replay();
active.set_repeat(bevy::animation::RepeatAnimation::Never);
active.seek_to(seek);
active.set_speed(speed);
active.set_weight(ONESHOT_OVERLAY_WEIGHT); // `blendFlag = 0` — no ramp, no cross-fade
drv.overlay = Some(Overlay {
node: upper,
id,
looping: false,
});
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"fct",
&format!("anim transplant unit={entity} id={id} -> key-bone at {seek:.3}s (bone 0 takes the request)"),
);
}
true
}
/// The animation state machine, per unit (decision 0049 + 0073). Death overrides; otherwise:
/// enter/loop/exit the Special states (jump, sit/sleep/kneel) as one-shot-bracketed loops, play the
/// per-packet melee swings as preemptible one-shots, and cross-fade the gaits (the engaged Ready
@@ -159,14 +307,18 @@ pub(super) fn drive_animations(
// The shared emote-audio catalog, promoted here for its `Emotes.dbc` → `AnimID` column: the
// looping state-emote idle (`UNIT_NPC_EMOTESTATE`) below resolves through it, same as the
// `SMSG_EMOTE` one-shot consumer ([`emote_anim::emote_to_anim`]).
// One tuple param (the 16-SystemParam ceiling): the shared emote-audio catalog and the
// client-local loot-target latch.
// One tuple param (the 16-SystemParam ceiling): the shared emote-audio catalog, the
// client-local loot-target latch, and the frame clock.
aux: (
Option<Res<EmoteSounds>>,
// The loot-target latch (decision 0515): the SELF unit's kneel trigger — armed at the
// `CMSG_LOOT` send, dropped at release/refusal — read by the loot leg below. `Option`
// for the headless test worlds that don't build the loot seam.
Option<Res<crate::ui_loot::LootLatch>>,
// The frame clock — the key-bone cross-fade's only time source (decision 0878): its
// retiring node is *frozen* on the clip's final frame, so unlike the wound's decay there
// is no playback clock to read the λ window off.
Res<Time>,
),
// The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream,
// shared by every play; [`select::msvc_rand`]).
@@ -175,7 +327,8 @@ pub(super) fn drive_animations(
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<Option<String>>,
) {
let (emote_sounds, loot_latch) = aux;
let (emote_sounds, loot_latch, time) = aux;
let dt = time.delta_secs();
// This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed
// in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order,
// scene-time emitters stamp after it). Order matters twice over: the later call overwrites
@@ -315,6 +468,11 @@ pub(super) fn drive_animations(
// right by construction, since the rider's body holds Mount(91) regardless and stealth and
// mounts are mutually exclusive anyway.
mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed());
// Stunned — read the same way and for the same reason (decision 0880): the fidget bail is a
// descriptor test in the reference too (`0x5eb4f2`/`0x5ec219`), on every unit it animates,
// so a watched player under Ice Block goes as still as ours does.
mv.stunned =
store.is_some_and(|s| s.0.unit_flags() & crate::player::UNIT_FLAG_STUNNED != 0);
let moving = mv.flags & move_flags::ANY_MOVE != 0;
// The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's
// FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's
@@ -351,7 +509,13 @@ pub(super) fn drive_animations(
// `variationIdx = −1`, decision 0123) or is forced to the head: decided from the state
// *before* this frame's transitions (the client tests the outgoing armed id).
let outgoing = drv.active_anim().unwrap_or(STAND);
let relaxed = !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing);
// …and never while stunned (decision 0880): the reference's idle/fidget selectors bail on
// `UNIT_FLAG_STUNNED` before they get to choose anything, so the re-arm holds the head and
// even the idle twitch stops. Its own gate, not `arm_forces_head`'s combat/cast re-zero —
// Ice Block is neither engaged nor casting, and the two suppressions sit at different sites
// in the binary (`0x5eb4f2`/`0x5ec219` vs `0x5fdba0`).
let relaxed =
!mv.stunned && !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing);
wound_upkeep(&mut drv, &mut player);
@@ -482,40 +646,31 @@ pub(super) fn drive_animations(
anims,
wielded,
cur,
req.state,
catalog,
);
}
}
}
// Ceremony upkeep: unpin the weapon swap when a clip crosses the authored event (or every
// overlay vanished — never stick the placement stale), and stop each finished overlay.
// The unpin IS the hand-touches-weapon moment: the draw/stow ring fires here
// (`sound::sheathe` — the sound can't ride the `$SHL`/`$SHR` tags, hip clips carry none).
// Ceremony upkeep, per arm: move that arm's weapon when its clip crosses the authored
// `$SHL`/`$SHR` event (the hand-touches-weapon moment — the draw/stow ring fires there,
// `sound::sheathe`, since the sound can't ride the tags themselves: hip clips carry none),
// and when a *stow* clip finishes, run the client's **phase 2** — the on-anim-finish
// drawer `0x5fc920` @ `0x5fca8c`/`0x5fcaa1`. That deferred second movement is what makes a
// melee → ranged toggle read as "put the swords away, come back to neutral, *then* reach
// over the shoulder for the bow" instead of one blended swap.
let sheath_now = drv.sheath_cur.unwrap_or(0);
if let Some(sw) = &mut drv.sheath_swap {
let mut any_alive = false;
let mut crossed = false;
for node in sw.nodes.into_iter().flatten() {
if let Some(a) = player.animation(node) {
any_alive = true;
crossed |= a.seek_time() >= sw.swap_at;
if a.is_finished() {
player.stop(node);
}
}
}
if (crossed || !any_alive) && !sw.unpinned {
sw.unpinned = true;
commands.entity(entity).remove::<VisualSheath>();
sheath_swaps.write(SheathSwapMessage {
entity,
drawing: sheath_now != 0,
});
}
if !any_alive {
drv.sheath_swap = None;
}
}
advance_sheath_ceremony(
&mut commands,
entity,
&mut drv,
&mut player,
anims,
wielded,
sheath_now,
catalog,
&mut sheath_swaps,
);
// ── This frame's one-shot: a melee swing (decision 0073 — one per SMSG_ATTACKERSTATEUPDATE)
// or an anim-emote (decision 0081). A swing outranks an emote when both land (combat wins).
@@ -636,11 +791,6 @@ pub(super) fn drive_animations(
}
continue;
}
// A prior overlay is superseded by this play — stop its node so the new route owns the
// subtree (a fresh masked node re-takes it below, or a full-body route reclaims the base).
if let Some(ov) = drv.overlay.take() {
player.stop(ov.node);
}
// Mounted forces the masked route (byte-verified, wow-re mount-composition B1:
// upper-body one-shots route to the key-bone while mounted, and the 91-force would
// reclaim bone 0 on the next play anyway): a full-body /wave would replace the seat
@@ -657,11 +807,23 @@ pub(super) fn drive_animations(
.flatten();
if let Some((node, repeat)) = upper {
// Masked route: the SpineLow overlay, beside `mode`. The base machine runs untouched.
// The re-arm is **blended** (op4 `blendFlag = 1`, decision 0878): whatever held the
// key-bone retires into the fade slot and this clip rises over its own blendTime
// (`0x7125f2` — the INCOMING sequence's `M2Sequence+0x20`), so a masked swing never
// swaps the torso in one frame. Note the full-body branch below deliberately does
// NOT touch the overlay: on that route the client's key-bone slot keeps its current
// descriptor (a dedup no-op), so a standing emote over a still-running masked swing
// plays legs-only underneath it (`0x5fe930`; wow-re claim 6 CONFIRMED).
retire_overlay(
&mut drv,
&mut player,
picked.map_or(0.0, |(c, _)| c.blend_time.max(0.0)),
);
let active = player.play(node);
active.replay();
active.set_repeat(repeat); // always set — a reused node never keeps a stale count
active.set_speed(1.0); // nor a stale rate (a prior whiff slow-down, decision 0279)
active.set_weight(ONESHOT_OVERLAY_WEIGHT);
active.set_weight(0.0); // the fade upkeep raises it from λ = 1 this same frame
drv.overlay = Some(Overlay {
node,
id,
@@ -897,6 +1059,23 @@ pub(super) fn drive_animations(
// "restore". Leaving an airborne/pose `under` routes through
// [`leave_special`] exactly like the un-replaced machine: the latch
// handoff, the landing pick, and the pose exits all apply unchanged.
//
// …but FIRST the **transplant** (decision 0878): when what the base is about
// to play is a LOCOMOTION clip — a jump entry (37), a land pick (39/187) — and
// this one-shot is a live CAST/COMBAT clip, the client moves it up onto the
// key-bone rather than letting the request overwrite it. Fall(40) and the pose
// enters/exits are NOT locomotion ids, so a FALLINGFAR latch or a sit-down
// still replaces the clip on bone 0, exactly as the bytes order it.
let incoming_locomotion = match (under, special) {
(_, Some(next)) => select::is_locomotion(next.enter()),
(Some(select::Special::Jump | select::Special::Fall), None) => {
select::jump_land_pick(mv.flags).is_some_and(select::is_locomotion)
}
_ => false,
};
if incoming_locomotion {
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
}
drv.mode = if let Some(sp) = under {
leave_special(
sp,
@@ -965,6 +1144,12 @@ pub(super) fn drive_animations(
// cache clears with it (decision 0406; a finished clip instead had its
// cache consumed by the injection above, before this machine ran).
drv.deferred = None;
// …and it is a LOCOMOTION request, so a still-playing CAST/COMBAT clip
// transplants up to the key-bone instead of being overwritten (decision
// 0878). A *finished* clip has nothing to move: the client's descriptor
// probe reports a completed slot as id −1 (`0x5fe1f0` reads the completion
// latch, not the armed record), so the transplant predicates never see it.
transplant_up(&mut drv, &mut player, &tr, anims, entity, id);
}
drv.mode = Mode::Gait;
drv.gait = None; // recompute a fresh gait next frame
@@ -1207,42 +1392,38 @@ pub(super) fn drive_animations(
}
}
// The anim half of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]): one line per
// frame the SELF unit's settled anim state *changes* — mode, gait, Special, flags, or the
// rate-driving speed — interleaved with the mover's `move` lines on the same clock, so a
// feel report ("the char's frames snap on landing") pins which layer moved and when.
if is_self && crate::dbg_trace::enabled() {
let state = format!(
"mode={:?} gait={:?} special={:?} flags={:08x} speed={:.2}",
drv.mode, drv.gait, special, mv.flags, mv.speed
);
if anim_trace_last.as_deref() != Some(&state) {
crate::dbg_trace::line("anim", &state);
*anim_trace_last = Some(state);
}
}
// ── Masked one-shot completion (decision 0087 (c)): a finished overlay releases the SpineLow
// subtree back to the base — the legs' base clip reclaims the torso, no auto-expiry timer. A
// looping base underneath is never disturbed by the overlay's start or end (it drives the legs
// throughout). Started *this* frame it is not finished, so it survives to play. A looping
// cast-hold overlay never finishes — it is released by the hold block below instead.
// ── Masked one-shot completion — the **fade-to-rest** (decision 0878, correcting 0087 (c)):
// a finished overlay is NOT stopped. The client latches the completion in its per-frame
// model advance (`0x719370`) and delivers a deferred event the same frame to
// `CGUnit::OnAnimationFinished 0x5fc920`, which disarms the key-bone through op4
// `param_3 = -1` — snapshotting the clip's **held final frame** into the bone's secondary
// slot and cross-fading it back onto the inherited base over a fixed 150 ms. So the torso's
// return is a blend out of the follow-through, never the one-frame cut this used to do
// (the director's "the end of the cast is cut off and it instantly snaps back"). A looping
// cast-hold overlay never completes — the hold block below owns its release.
if let Some(ov) = drv.overlay {
let done = if ov.looping {
false // a cast-hold loop: the hold block below owns its release
} else if find_resolved(anims, ov.id, catalog).is_some_and(|c| c.looping) {
// A hold-less overlay whose CLIP is authored looping (a pushed kit's seated
// eat/drink gesture) has no natural end — release it when the body is claimed:
// movement or a Special transition. INTERIM (decision 0280): the client's exact
// release chain for this case is unpinned; supersede-by-a-later-play already
// evicts above.
moving || special.is_some()
// eat/drink gesture) has no natural end — release it when the body is claimed by
// MOVEMENT. INTERIM (decision 0280): the client's exact release chain for this case
// is unpinned; supersede-by-a-later-play already evicts above. A **Special** no
// longer counts (decision 0878): a jump is a bone-0 play, and bone-0 plays never
// touch the key-bone slot — cutting the torso on takeoff was the same defect the
// fade above fixes at the other end.
moving
} else {
player.animation(ov.node).is_none_or(|a| a.is_finished())
};
if done {
player.stop(ov.node);
drv.overlay = None;
// Freeze the held frame, then retire it: the client's snapshot copies an already
// *expired* window, so the secondary clamps to `seq.end` and the fade runs from
// the clip's final pose (`oneshot-lifecycle.md` §5.4).
if let Some(a) = player.animation_mut(ov.node) {
a.set_speed(0.0);
}
retire_overlay(&mut drv, &mut player, ONESHOT_RELEASE_FADE);
}
}
@@ -1254,24 +1435,31 @@ pub(super) fn drive_animations(
// masked one-shot in the slot (a swing over the run) wins while it plays; the hold
// re-takes the subtree the frame after it finishes (the client's last-writer-wins slot).
// Its start is a play like any other, so the sheath reconcile below sees it (`hold_played`).
// A **Special no longer cancels it** (decision 0878): jumping mid-cast is a bone-0 play
// (JumpStart takes the legs) and the client's key-bone slot is untouched by it —
// `0x5fe912` routes a locomotion request straight to bone 0 when the key-bone is armed.
// Dropping the hold on takeoff was exactly the director's "jump-running cuts the cast".
let mut hold_played: Option<u16> = None;
let masked_hold = cast_hold
.filter(|_| mv.flags & move_flags::CAST_PIN_MOVE != 0 && special.is_none())
.filter(|_| mv.flags & move_flags::CAST_PIN_MOVE != 0)
.and_then(|h| {
find_resolved(anims, h.anim_id, catalog)
.and_then(|c| c.upper_node)
.map(|node| (h.anim_id, node))
find_resolved(anims, h.anim_id, catalog).and_then(|c| {
c.upper_node
.map(|node| (h.anim_id, node, c.blend_time.max(0.0)))
})
});
match (masked_hold, drv.overlay) {
(Some((id, node)), prior) if prior.is_none_or(|ov| ov.looping && ov.id != id) => {
// Free slot, or a stale hold loop (the channel switched spells): (re)take it.
if let Some(ov) = prior {
player.stop(ov.node);
}
(Some((id, node, blend)), prior)
if prior.is_none_or(|ov| ov.looping && ov.id != id) =>
{
// Free slot, or a stale hold loop (the channel switched spells): (re)take it — a
// blended key-bone re-arm like any other (decision 0878), so the outgoing pose
// retires under the hold's own blendTime instead of vanishing.
retire_overlay(&mut drv, &mut player, blend);
let active = player.play(node);
active.replay();
active.repeat();
active.set_weight(ONESHOT_OVERLAY_WEIGHT);
active.set_weight(0.0); // the fade upkeep raises it from λ = 1 this same frame
drv.overlay = Some(Overlay {
node,
id,
@@ -1280,13 +1468,49 @@ pub(super) fn drive_animations(
hold_played = Some(id);
}
(None, Some(ov)) if ov.looping => {
// The hold ended, or the unit stopped/entered a Special — release the subtree
// (the standing pin or the gait takes over from here).
player.stop(ov.node);
drv.overlay = None;
// The hold ended, or the unit stopped — release the subtree (the standing pin or
// the gait takes over). A looping clip never completes, so the client's
// completion-driven release never fires for it and its true release chain is
// still unpinned (0107/0280 INTERIM); it borrows the verified fade-to-rest shape
// rather than snapping, which is right either way.
retire_overlay(&mut drv, &mut player, ONESHOT_RELEASE_FADE);
}
_ => {}
}
// The key-bone cross-fade's advance, last — after every arm/release this frame, so the
// weights land on the slot's settled state (the client's kernel likewise runs λ after the
// frame's PlayAnimation calls).
overlay_fade_upkeep(&mut drv, &mut player, dt);
// The anim half of the `WOW_MOVE_TRACE` debug trace ([`crate::dbg_trace`]): one line per
// frame the SELF unit's settled anim state *changes* — mode, gait, Special, flags, the
// rate-driving speed, and **both slots** — interleaved with the mover's `move` lines on the
// same clock, so a feel report ("the char's frames snap on landing") pins which layer moved
// and when. It runs HERE, after every arm/release/fade this frame, so `upper` is the
// settled key-bone state: without it a torso report ("the cast got cut") could not be told
// from a base one at all (decision 0878). `+fade` marks the 150 ms fade-to-rest running —
// a boolean, not a countdown, so a fade doesn't spam a line per frame.
if is_self && crate::dbg_trace::enabled() {
let state = format!(
"mode={:?} gait={:?} special={:?} flags={:08x} speed={:.2} upper={:?}{}",
drv.mode,
drv.gait,
special,
mv.flags,
mv.speed,
drv.overlay.map(|ov| ov.id),
if drv.overlay_fade.is_some() {
"+fade"
} else {
""
},
);
if anim_trace_last.as_deref() != Some(&state) {
crate::dbg_trace::line("anim", &state);
*anim_trace_last = Some(state);
}
}
let masked_played = masked_played || hold_played.is_some();
let base_played = base_played || (drv.mode, drv.gait) != pre_state;
@@ -147,6 +147,7 @@ fn stationary_cast_hold_stows_an_engaged_casters_weapon() {
ranged: None,
main_sheath: 2,
off_sheath: 0,
..Default::default()
},
))
.id();
@@ -200,6 +201,7 @@ fn moving_cast_hold_keeps_its_stow_between_plays() {
ranged: None,
main_sheath: 2,
off_sheath: 0,
..Default::default()
},
MovementState {
speed: 7.0,
@@ -390,6 +392,7 @@ fn cast_hold_stows_even_when_the_model_lacks_the_spell_anims() {
ranged: None,
main_sheath: 2,
off_sheath: 0,
..Default::default()
},
))
.id();
@@ -1492,6 +1495,250 @@ fn a_moving_jump_cast_masks_onto_the_overlay() {
);
}
/// **The transplant** (decision 0878 — the director's "jump right after a cast should only play
/// the lower body animation and finish the upper body one"). A standing cast routes full-body to
/// bone 0; the jump that follows is a LOCOMOTION request, so the client does **not** overwrite it
/// — `0x5fe919` copies the bone-0 descriptor (id, rate, and its **live play position**) onto the
/// key-bone and hands bone 0 the jump. The legs jump; the arms finish the cast.
#[test]
fn a_jump_over_a_live_cast_transplants_it_to_the_torso() {
let mut app = app();
let unit = jumper(&mut app);
app.update(); // settle: Stand
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
{
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
assert!(
matches!(drv.mode, super::super::select::Mode::Swing { id: 54, .. }),
"standing: the cast takes the FULL BODY on bone 0"
);
assert!(drv.overlay.is_none(), "nothing on the key-bone yet");
}
// Jump in place, mid-cast.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FALLING,
vertical_speed: 7.9,
..Default::default()
});
app.update();
let drv = app.world().entity(unit).get::<AnimDriver>().unwrap();
assert_eq!(
drv.overlay.map(|ov| (ov.id, ov.node)),
Some((54, AnimationNodeIndex::new(8))),
"the cast transplants onto the SpineLow overlay instead of being replaced"
);
assert_eq!(
drv.mode,
super::super::select::Mode::Entering(super::super::select::Special::Jump),
"…and the legs get the jump"
);
assert!(
drv.overlay_fade.is_none(),
"a transplant carries blendFlag = 0: it resumes mid-clip, it does not cross-fade"
);
}
/// A **Special is a bone-0 play, so it cannot cut the torso** (decision 0878 — the jump-running
/// half of the director's report). A moving caster's hold rides the key-bone; taking off routes
/// JumpStart to bone 0 (`0x5fe912`: with the key-bone armed the locomotion request goes straight
/// there) and leaves the hold running. The old `special.is_none()` filter dropped it on takeoff.
#[test]
fn a_jump_does_not_cut_the_moving_cast_hold() {
let mut app = app();
let unit = app
.world_mut()
.spawn((
caster_model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
},
CastHold {
anim_id: 51,
spell_id: 1,
ranged: false,
},
))
.id();
app.update();
assert!(
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.is_some_and(|ov| ov.id == 51 && ov.looping),
"a moving caster holds on the torso"
);
// Take off, still running, still casting.
app.world_mut().entity_mut(unit).insert(MovementState {
flags: move_flags::FORWARD | move_flags::FALLING,
vertical_speed: 7.9,
speed: 7.0,
..Default::default()
});
app.update();
assert!(
app.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.is_some_and(|ov| ov.id == 51 && ov.looping),
"the jump takes bone 0 — the hold keeps the torso"
);
}
/// **The fade-to-rest** (decision 0878 — "the end of the cast animation is cut off and it
/// instantly snaps back"). A finished key-bone one-shot is never stopped: the client's completion
/// event disarms the bone through op4 `param_3 = -1`, which holds the clip's final frame in the
/// secondary slot and cross-fades it back onto the base over a fixed 150 ms. Real clip assets, so
/// Bevy actually completes the overlay.
#[test]
fn a_finished_masked_cast_fades_out_instead_of_snapping() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = app();
const CAST: f32 = 0.3;
let run_handle = {
let mut c = AnimationClip::default();
c.set_duration(1.0);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
let cast_handle = {
let mut c = AnimationClip::default();
c.set_duration(CAST);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
};
// Three nodes: the run base, the cast's full-body node, and the cast's masked twin.
let (graph, nodes) = AnimationGraph::from_clips([run_handle, cast_handle.clone(), cast_handle]);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let mut run = clip(5, 0, true);
run.node = nodes[0];
let mut cast = clip(54, 0, false);
cast.node = nodes[1];
cast.duration = CAST;
cast.blend_time = 0.05;
cast.upper_node = Some(nodes[2]);
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: graph_handle.clone(),
clips: vec![run, cast],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
MovementState {
flags: move_flags::FORWARD,
speed: 7.0,
..Default::default()
},
))
.id();
app.update(); // settle: Run
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 54,
seq: 1,
});
app.update();
{
let e = app.world().entity(unit);
let drv = e.get::<AnimDriver>().unwrap();
assert!(
drv.overlay.is_some_and(|ov| ov.node == nodes[2]),
"moving: the cast masks onto the torso"
);
assert!(
drv.overlay_fade.is_some_and(|f| f.out.is_none()),
"the arm is blended: it rises over its own blendTime, from the base pose"
);
let w = e.get::<AnimationPlayer>().unwrap().animation(nodes[2]);
assert!(
w.is_some_and(|a| a.weight() < super::ONESHOT_OVERLAY_WEIGHT),
"…so it starts below full weight, not snapped on"
);
}
// Step in small frames until the clip completes (a big frame would run the whole 150 ms fade
// out in one go — right behaviour, useless assertion).
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(20));
app.update();
if app
.world()
.entity(unit)
.get::<AnimDriver>()
.unwrap()
.overlay
.is_none()
{
break;
}
}
{
let e = app.world().entity(unit);
let drv = e.get::<AnimDriver>().unwrap();
assert!(drv.overlay.is_none(), "the cast finished");
assert_eq!(
drv.overlay_fade.and_then(|f| f.out),
Some(nodes[2]),
"…and retired into the fade slot rather than being dropped"
);
let active = e
.get::<AnimationPlayer>()
.unwrap()
.animation(nodes[2])
.expect("the finished clip is still driving the torso, holding its last frame");
assert_eq!(
active.speed(),
0.0,
"held on the final frame, not replaying"
);
assert!(active.weight() > 0.0, "still blended in as λ decays");
}
// Past the 150 ms window: the slot self-releases, exactly like the kernel's `+0xd0 = -1`.
std::thread::sleep(std::time::Duration::from_millis(200));
app.update();
let e = app.world().entity(unit);
assert!(
e.get::<AnimDriver>().unwrap().overlay_fade.is_none(),
"the fade window expired"
);
assert!(
e.get::<AnimationPlayer>()
.unwrap()
.animation(nodes[2])
.is_none(),
"…and the node is released"
);
}
/// The airborne-freeze in the GAIT slot (the step-off arc, decision 0864): live pins — here
/// the stationary cast hold — cannot swap the clip mid-air; the takeoff gait keeps rolling and
/// the pin applies at touchdown.
@@ -1678,6 +1925,7 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
ranged: Some((2, 0x2)), // bow
main_sheath: 3,
off_sheath: 0,
..Default::default()
},
))
.id();
@@ -1734,3 +1982,135 @@ fn a_landed_swing_snaps_its_attacker_out_of_the_ranged_stance() {
swing(&mut app);
assert_eq!(sheath(&app), Some(1), "melee holds across the volley");
}
/// **The ceremony's two movements, end to end** (decision 0872) — the director's report: pressing
/// Z with both hands full puts the weapons away, lets the arms come back to neutral, and only
/// *then* reaches over the shoulder for the bow. Phase 1 is the setter's own play (`0x611b60`);
/// phase 2 is the on-anim-finish drawer (`0x5fc920` @ `0x5fca8c`/`0x5fcaa1`), which is the half
/// benilla never had — before it, the ceremony ended when the stow clips did and the bow simply
/// appeared. Real clip assets, so Bevy's `advance_animations` actually completes them.
#[test]
fn a_melee_to_ranged_toggle_stows_both_hands_before_it_reaches_for_the_bow() {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = app();
const DUR: f32 = 0.1;
let handles: Vec<_> = (0..5)
.map(|_| {
let mut c = AnimationClip::default();
c.set_duration(DUR);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
})
.collect();
let (graph, nodes) = AnimationGraph::from_clips(handles);
let graph_handle = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
// The two stow/draw families, each with its per-arm masked pair. No `$SHL`/`$SHR` events, so
// each arm's weapon moves at the authored-event fallback: halfway.
let family = |id: u16, right: usize, left: usize| {
let mut c = clip(id, 0, false);
c.node = nodes[right];
c.duration = DUR;
c.blend_time = 0.0;
c.arm_nodes = Some((nodes[right], nodes[left]));
c
};
let mut stand = clip(0, 0, true);
stand.node = nodes[0];
stand.duration = DUR;
let anims = ModelAnimations {
graph: graph_handle.clone(),
clips: vec![stand, family(90, 1, 2), family(89, 3, 4)],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
// Sword-and-board plus a bow — the director's warrior. Hip sword (3 ⇒ HipSheath 90), back
// shield (4 ⇒ Sheath 89), back bow (1 ⇒ 89, and INVTYPE_RANGED ⇒ the LEFT arm).
let unit = app
.world_mut()
.spawn((
anims,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph_handle),
AnimDriver::default(),
// The Z toggle is the local player's alone; a remote unit's committed state is pulled
// back to the server byte by the reconcile's rule 5 before any ceremony could run.
crate::net::SelfPlayer,
Wielded {
main: Some((2, 0x7)),
off: Some((4, 6)),
ranged: Some((2, 0x2)),
main_sheath: 3,
off_sheath: 4,
ranged_sheath: 1,
ranged_inv: 0x0f,
materials: [1, 6, 2],
},
))
.id();
let visual = |app: &App| {
app.world()
.entity(unit)
.get::<crate::creature_anim::VisualSheath>()
.map(|v| v.0)
};
// Get to melee-drawn without a ceremony (a snap, as every reactive trigger does), then press Z.
for (state, ceremony) in [(1u8, false), (2, true)] {
app.world_mut().write_message(SheathRequest {
entity: unit,
state,
ceremony,
});
app.update();
}
assert_eq!(
visual(&app),
Some([1, 1]),
"phase 1: both hands still hold their weapons while the stow clips play"
);
let mut seen = vec![[1u8, 1]];
let mut settled = false;
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(20));
app.update();
match visual(&app) {
Some(v) if seen.last() != Some(&v) => seen.push(v),
None => {
settled = true;
break;
}
_ => {}
}
}
// The smoking gun: a state where the RIGHT arm has settled into the ranged stance (sword on
// the back, nothing left to do) while the LEFT is still empty-handed — the bow on its way but
// not yet arrived. That can only exist if a second clip started after the stows finished.
assert!(
seen.contains(&[2, 0]),
"phase 2 never ran: the bow must be drawn by a SECOND clip, after both stows finished \
(saw {seen:?})"
);
let stow = seen.iter().position(|v| *v == [1, 1]).unwrap();
let reach = seen.iter().position(|v| *v == [2, 0]).unwrap();
assert!(
reach > stow,
"the reach must follow the stow, not blend with it (saw {seen:?})"
);
assert!(
settled,
"the ceremony must end with the pin dropped, leaving the committed state (saw {seen:?})"
);
}
+55 -2
View File
@@ -267,6 +267,18 @@ pub(crate) struct MovementState {
/// legs never touch it: unlike the stand state there is nothing to predict controller-side —
/// the crouch waits on the server's aura, exactly as the reference does.
pub(crate) stealthed: bool,
/// **Stunned** — `UNIT_FIELD_FLAGS & 0x40000`, stamped from the unit's OWN descriptor every
/// frame like [`Self::stealthed`] (self and remote alike). It gates the **idle fidget**: the
/// reference's idle/fidget selectors bail on the bit outright (`0x5eb4f2`, `0x5ec219` —
/// `test dword ptr [eax+0xa0], 0x40000` → `jne` out), so a stunned unit holds one deterministic
/// idle instead of looking around.
///
/// That is the *whole* of the stun's animation effect — there is no stun clip anywhere in the
/// `0x40000` census (wow-re `unit-flags-movement-gates.md` §3). The rest of the frozen look is
/// the ROOT half doing its job: a stun always arrives with one, and a rooted unit carries no
/// direction bit, so the movement-anim resolver takes its not-moving exit and the body stands
/// (decision 0880).
pub(crate) stunned: bool,
/// Riding a **flying** server spline (the taxi flight): the client's selector reads the active
/// CMovement's spline flags (`[[unit+0x118]+0xa4]+0x18`) and plays Fly 135 when the wire Flying
/// bit (0x200) is set — RF-0057 `0x5fd19c`. Stamped by [`unify`] from the entity's live
@@ -354,9 +366,17 @@ impl Special {
/// one-shot at all**, dropping straight into the gait (the reference backpedals the instant it
/// touches down; 187 is a forward-run footplant and never plays backward — the "forward run flash
/// after a jump-then-hold-S" bug). `None` = no landing clip, go straight to `Mode::Gait`.
///
/// **A ROOTED arc end is not a landing** (decision 0880). A root or a stun caught mid-air ends the
/// fall where the body hangs — `SetRoot 0x7c7340`'s `StopFalling` clears FALLING with no ground
/// involved — and the reference suppresses the land packet on exactly that state
/// (`0x602df3 test ah,0x10` gating opcode `0xc9`). This dispatcher *is* that packet's consumer
/// (`0x602c60` runs on the FALL_LAND apply, local and observed alike), so no packet means no pick:
/// the pose starves to Stand instead of playing JumpEnd in mid-air. Same suppression, same byte
/// site, as the wire half in [`crate::player`]'s arc bookkeeping.
pub(super) fn jump_land_pick(flags: u32) -> Option<u16> {
use move_flags::*;
if flags & SWIMMING != 0 {
if flags & (SWIMMING | ROOT) != 0 {
None
} else if flags & ANY_MOVE == 0 {
Some(39)
@@ -614,6 +634,17 @@ pub(super) fn is_combat_anim(id: u16) -> bool {
matches!(id, 10 | 16..=24 | 30 | 36 | 57..=59 | 85..=88 | 95 | 117 | 118)
}
/// The client's **CAST** classifier (`0x5fcbb0` — byte-decoded in wow-re `oneshot-lifecycle.md`
/// §7, previously unlabelled): the spell-cast release anims `{2, 32, 33, 53, 54}`. Together with
/// [`is_combat_anim`] it is what the **transplant** predicate (`0x5feae0`) tests on the *currently
/// armed bone-0 clip*: a locomotion request over one of these does not replace it — the clip moves
/// up onto the key-bone at its live play position ([`OneShotRoute`]'s two slots, decision 0878).
/// NOT the ReadySpell holds (51/52) — those are their own set (`0x5fde40`), and a jump over a
/// standing hold really does take the whole body.
pub(super) fn is_cast_anim(id: u16) -> bool {
matches!(id, 2 | 32 | 33 | 53 | 54)
}
/// Whether `cands` (this frame's [`gait_candidates`] pick) is bare `[STAND]` — the one precedence
/// slot the looping **state-emote idle** (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC cooking/sweeping
/// flavor loops) is allowed to fill instead, exactly where Stand itself would otherwise sit.
@@ -807,6 +838,18 @@ pub(super) fn wound_weight(remaining_frac: f32, others: f32) -> f32 {
others * lambda / (1.0 - lambda)
}
/// The client's per-bone blend weight λ at amplitude 1.0 (`0x714880`–`0x714921`): `smoothstep(t)`
/// with `smoothstep(t) = (3 − 2t)·t²` and `t` the fraction of the blend window **still to run**,
/// so λ **decays 1 → 0** across it. Shared by the key-bone slot's two cross-fades (decision 0878):
/// the fade-to-rest that releases a finished one-shot over a fixed 150 ms (op4 `param_3 = −1`,
/// `0x7123af`: `+0x104 = 1/150`, amplitude `+0x108 = 1.0`) and the blended re-arm that cross-fades
/// a new masked clip in over the *incoming* sequence's own blendTime (`0x7125f2`). The wound's
/// twin ([`wound_weight`]) is the same curve at amplitude 0.75.
pub(super) fn blend_lambda(remaining_frac: f32) -> f32 {
let t = remaining_frac.clamp(0.0, 1.0);
(3.0 - 2.0 * t) * t * t
}
/// The client's `_rand` — the MSVCRT LCG (`state × 214013 + 2531011`, output `(state >> 16) &
/// 0x7fff`; byte-verified wow-re `rf36-rand-stub.md` at `0x7400e5`) — the roll feeding op4's
/// per-play **variation pick** (`ModelAnimations::pick_variation`) and its **replay-count roll**
@@ -931,9 +974,19 @@ pub(super) fn route_oneshot(id: u16, flags: u32, stand_state: u8) -> OneShotRout
}
/// `AnimationData` ids whose playback rate the client scales by movement speed (wow-5875-re `0x5fee80`):
/// the locomotion gaits. An id outside this set plays at rate 1×.
/// the locomotion gaits. An id outside this set plays at rate 1×. The **same** table is the
/// LOCOMOTION membership the transplant predicates key on ([`is_locomotion`]).
const RATE_SCALED: &[u16] = &[4, 5, 11, 12, 13, 37, 38, 39, 42, 43, 44, 45, 135, 143, 187];
/// The client's **LOCOMOTION** membership (`0x5fee80` — the very table the rate scaler uses):
/// what the transplant predicates test as "the clip being *requested* is a base locomotion clip"
/// (`0x5feae0`/`0x5fe912`, wow-re `oneshot-lifecycle.md` §3a). Note Fall(40) and SwimIdle(41) are
/// **not** members — a FALLINGFAR latch mid-cast replaces the cast on bone 0 rather than
/// transplanting it, exactly as the bytes order it.
pub(super) fn is_locomotion(id: u16) -> bool {
RATE_SCALED.contains(&id)
}
/// The playback rate for a clip given the unit's live speed (wow-5875-re `0x5fe2f0`): `speed / moveSpeed`
/// for a rate-scaled locomotion clip, else 1×. (The client also divides by the model's scale magnitude,
/// 1.0 for an unscaled character.) Guarded on `moveSpeed > 0`.
@@ -204,6 +204,12 @@ fn jump_land_pick_is_the_0x602c60_rule() {
assert_eq!(jump_land_pick(FORWARD | WALK_MODE), None);
assert_eq!(jump_land_pick(SWIMMING), None);
assert_eq!(jump_land_pick(FORWARD | SWIMMING), None);
// …and ROOTED → no clip either (decision 0880): a root or a stun caught mid-air ends the fall
// in mid-air, and the reference suppresses the FALL_LAND this dispatcher runs on
// (`0x602df3`). Without it the body plays JumpEnd 39 while hanging 400 yd up — measured, and
// exactly what "the animation doesn't freeze" looked like.
assert_eq!(jump_land_pick(ROOT), None);
assert_eq!(jump_land_pick(ROOT | FORWARD), None);
}
#[test]
+420 -60
View File
@@ -12,25 +12,78 @@ use crate::assets::{LockRecover, WorldAssets};
use super::{find_resolved, select, AnimDriver, Wielded};
/// Holds a unit's *visual* sheath state at the pre-transition value while the draw/stow one-shot
/// plays, so the weapon models swap hands↔hip/back at the animation's authored `$SHL`/`$SHR` event
/// (~the moment the hand reaches the stow point — 500/567 ms into the 1 s clips, dumped from
/// HumanMale.m2) instead of the instant the state changes. Removed at the swap point — the
/// equipment resolver then falls through to the committed sheath state. Absent = no transition
/// in flight.
/// Holds a unit's *visual* sheath state **per arm** while the draw/stow one-shots play, so each
/// weapon model swaps hand↔hip/back at its own clip's authored `$SHL`/`$SHR` event (~the moment
/// that hand reaches the stow point — 500/567 ms into the 1 s clips, dumped from HumanMale.m2)
/// instead of the instant the state changes. Per-arm because the ceremony is: a warrior going
/// melee → ranged stows both hands *and then* reaches for the bow, so the sword leaves the right
/// hand a full clip before the bow arrives in the left. Removed when every arm has settled — the
/// equipment resolver then falls through to the committed sheath state. Absent = no transition in
/// flight. Index by [`ARM_RIGHT`]/[`ARM_LEFT`] — or by held slot, via [`Self::for_slot`].
#[derive(Component, Clone, Copy)]
pub(crate) struct VisualSheath(pub(crate) u8);
pub(crate) struct VisualSheath(pub(crate) [u8; 2]);
/// The draw/stow ceremony in flight: up to two **per-arm masked one-shots** (mainhand on the right
/// arm, offhand on the left — the client's per-slot `0x60b770` plays, composed over whatever the
/// body is doing: walk, run, jump; never cancelled), plus the pending mid-animation weapon swap —
/// [`VisualSheath`] pins the old placement until a clip crosses `swap_at` (the authored
/// `$SHL`/`$SHR` moment).
impl VisualSheath {
/// The effective sheath state governing one **held slot**'s placement: mainhand on the right
/// arm, offhand on the left, and the ranged item on whichever arm its `InventoryType` puts it
/// (the byte-verified `0x1a`/`0x19` compare — see [`ranged_arm`]).
pub(crate) fn for_slot(self, slot: usize, inv_type: u32) -> u8 {
self.0[match slot {
0 => ARM_RIGHT,
1 => ARM_LEFT,
_ if matches!(inv_type, 0x1a | 0x19) => ARM_RIGHT,
_ => ARM_LEFT,
}]
}
}
/// One arm's leg of the ceremony in flight — a masked one-shot on that arm's subtree (the client's
/// per-slot `0x60b770` plays on sub-sequence 3/2), composed over whatever the body is doing: walk,
/// run, jump; never cancelled.
pub(super) struct SheathArm {
node: bevy::animation::graph::AnimationNodeIndex,
/// The authored `$SHL`/`$SHR` moment — when this arm's weapon actually moves.
swap_at: f32,
/// This clip ends with the weapon **in the hand** — the client's `+0xd58` phase bit (see
/// [`ArmLeg::drawing`]). `false` = a stow, and the one thing phase 2 keys on.
drawing: bool,
/// The held slot this leg moves: 0 mainhand · 1 offhand · 2 ranged.
slot: u8,
/// The clip has passed [`Self::swap_at`] — the weapon has moved, the sound has rung.
crossed: bool,
}
/// The draw/stow ceremony in flight — up to one leg per arm, each advancing independently.
pub(super) struct SheathSwap {
/// The playing masked nodes (right-arm, left-arm) — `None` per empty hand.
pub(super) nodes: [Option<bevy::animation::graph::AnimationNodeIndex>; 2],
pub(super) swap_at: f32,
pub(super) unpinned: bool,
arms: [Option<SheathArm>; 2],
/// The state the ceremony left (the client's `+0xd3c` PREV): the pre-swap placement for a
/// stow leg, and phase 2's gate.
prev: u8,
}
impl SheathSwap {
/// The effective sheath state for one arm: a stow leg shows [`Self::prev`] until its weapon
/// moves and stowed (0) after; a draw leg shows stowed until its weapon arrives and the
/// committed state after; an arm with no leg is simply snapped.
fn arm_state(&self, arm: usize, cur: u8) -> u8 {
match &self.arms[arm] {
None => cur,
Some(a) if a.drawing => {
if a.crossed {
cur
} else {
0
}
}
Some(a) => {
if a.crossed {
0
} else {
self.prev
}
}
}
}
}
/// The per-arm overlay's weight over the gait on the arm bones both key: the ceremony dominates the
@@ -88,15 +141,137 @@ pub(crate) fn toggle_sheath_next(cur: u8, (melee, ranged): (bool, bool)) -> Opti
}
}
/// One executed draw/stow swap at its hand-touches-weapon moment — fired only by the *ceremony*
/// (the [`VisualSheath`] release at the authored `$SHL`/`$SHR` event). Snap transitions never
/// fire it: in the client the draw/stow sound rides the ceremony clip's own keyframes, and a
/// One arm's leg of a draw/stow ceremony: the clip to play, and whether that arm ends with its
/// weapon **in the hand**. `drawing` is the client's `+0xd58` per-arm phase bit — set by the
/// drawers (`0x6118a0 |= 0x100000`, `0x611960 |= 0x200000`, `0x611a20 |= 0x300000`), cleared by
/// every stow leg (`0x611b60`'s `& 0xffefffff` / `& 0xffdfffff`). Layer B reads it at the clip's
/// `$SHL`/`$SHR` event to decide hand-vs-sheath-bone, which is exactly what it means here.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct ArmLeg {
pub(super) clip: u16,
pub(super) drawing: bool,
}
/// Arm indices — the client's animation **sub-sequence** slots, which are per-arm: the mainhand
/// plays on 3 (HandRight), the offhand on 2 (HandLeft), and the ranged weapon on whichever its
/// `InventoryType` picks (bow ⇒ left, gun/crossbow/wand/thrown ⇒ right).
pub(super) const ARM_RIGHT: usize = 0;
pub(super) const ARM_LEFT: usize = 1;
/// Which arm the ranged weapon occupies, and whether one is worn at all. Byte-verified compare
/// (`0x611c74`, `0x6118db`, `0x611998`, `0x611a9a` — identical at every site): `[+3] == 0x1a`
/// (RANGEDRIGHT — gun/crossbow/wand) or `== 0x19` (THROWN) ⇒ the **right** arm; anything else
/// (`0x0f`, INVTYPE_RANGED — a bow) ⇒ the **left**.
fn ranged_arm(w: &Wielded) -> Option<usize> {
w.ranged.map(|_| {
if matches!(w.ranged_inv, 0x1a | 0x19) {
ARM_RIGHT
} else {
ARM_LEFT
}
})
}
/// The **draw** leg one arm would play for a committed state — the shared body of the client's
/// two per-arm drawers (`0x6118a0` right, `0x611960` left) and of the both-arms `0x611a20`, all
/// of which run the identical `(1 << (rec[+4] & 0x1f)) & 0x88` pick on the slot's own record.
/// `None` = that arm has nothing to draw for this state, and is released to its idle.
fn draw_leg(arm: usize, cur: u8, w: &Wielded) -> Option<ArmLeg> {
let (item, sheath) = match (cur, arm) {
(1, ARM_RIGHT) => (w.main, w.main_sheath),
(1, ARM_LEFT) => (w.off, w.off_sheath),
// The ranged weapon draws on exactly one arm; the other has no leg at all.
(2, _) if ranged_arm(w) == Some(arm) => (w.ranged, w.ranged_sheath),
_ => return None,
};
item.map(|_| ArmLeg {
clip: select::sheath_clip(sheath),
drawing: true,
})
}
/// **Phase 2** — the deferred draw, and the half of the ceremony benilla never had. The client
/// runs it from its **on-anim-finish** handler (`0x5fc920` @ `0x5fca62`–`0x5fcab6`): when a
/// `0x59`/`0x5a` clip finishes on sub-sequence 2 or 3 and that arm's phase bit is still **clear**
/// (i.e. what just finished was a *stow*), it calls that arm's drawer — `0x611960` @ `0x5fcaaf`
/// for the left, `0x6118a0` @ `0x5fca9a` for the right; a bit already set falls through to
/// `0x7121a0(subSeq, -1, …)`, releasing the arm to its idle. A leg here is the second full
/// movement: the arms come back to neutral, and only *then* does a hand reach for the new weapon.
///
/// The `prev == 0` refusal is the drawers' own first test (`0x6118a5` / `0x611965`:
/// `if (PREV == 0) return 0`) — a draw **out of the stowed state** is a single movement, already
/// played in full by [`sheath_phase1`]'s `0x611a20` leg, so there is nothing left to defer.
pub(super) fn sheath_phase2(arm: usize, prev: u8, cur: u8, w: &Wielded) -> Option<ArmLeg> {
(prev != 0).then(|| draw_leg(arm, cur, w)).flatten()
}
/// **Phase 1** — what the setter itself plays, keyed on **PREV** (the client's `0x611b60`, the
/// `bInstant == 0` fork of `SetSheatheState`). Byte-read at `0x611b60`–`0x611ce6`:
///
/// ```text
/// PREV 0 -> 0x611a20: draw BOTH arms for CUR now, set both bits — ONE movement
/// PREV 1 -> right: mainhand worn ? stow it : the right drawer (0x6118a0)
/// left : offhand worn ? stow it : the left drawer (0x611960)
/// PREV 2 -> no ranged item ? 0x611a20 (as PREV 0)
/// bow (left arm) : stow LEFT with a literal 89, right drawer (0x611c8c/0x611ca1)
/// gun/xbow/thrown : stow RIGHT with a literal 89, left drawer (0x611cd3/0x611ce1)
/// ```
///
/// So an arm with nothing to put away **draws immediately** — the ref's tail calls into the
/// drawers — while an arm that does put something away defers its draw to [`sheath_phase2`]. A
/// warrior with both hands full stows both, *then* reaches: two movements. A lone mainhand leaves
/// the other arm free, so the draw rides along with the stow: one.
///
/// **Named byte detail:** the PREV==2 stow legs push a literal `0x59` rather than running the
/// `& 0x88` pick (`0x611c8c`, `0x611cd3`), so a ranged weapon is always put away with Sheath 89
/// even where its own sheath type would have picked HipSheath 90 on the way out.
pub(super) fn sheath_phase1(prev: u8, cur: u8, w: &Wielded) -> [Option<ArmLeg>; 2] {
/// The ranged **stow** clip — a literal, not a pick (see above).
const RANGED_STOW: ArmLeg = ArmLeg {
clip: 89,
drawing: false,
};
let stow = |sheath: u8| {
Some(ArmLeg {
clip: select::sheath_clip(sheath),
drawing: false,
})
};
match prev {
// `LAB_00611ce6 -> 0x611a20`: nothing is in hand to put away, so both arms draw at once
// and no phase 2 can follow (the drawers' `PREV == 0` refusal).
0 => [draw_leg(ARM_RIGHT, cur, w), draw_leg(ARM_LEFT, cur, w)],
1 => [
match w.main {
Some(_) => stow(w.main_sheath),
None => sheath_phase2(ARM_RIGHT, prev, cur, w),
},
match w.off {
Some(_) => stow(w.off_sheath),
None => sheath_phase2(ARM_LEFT, prev, cur, w),
},
],
2 => match ranged_arm(w) {
None => [draw_leg(ARM_RIGHT, cur, w), draw_leg(ARM_LEFT, cur, w)],
Some(ARM_LEFT) => [sheath_phase2(ARM_RIGHT, prev, cur, w), Some(RANGED_STOW)],
Some(_) => [Some(RANGED_STOW), sheath_phase2(ARM_LEFT, prev, cur, w)],
},
_ => [None, None],
}
}
/// One executed draw/stow swap at its hand-touches-weapon moment — fired only by the *ceremony*,
/// **once per arm** as that arm's clip crosses its authored `$SHL`/`$SHR` event. Snap transitions
/// never fire it: in the client the draw/stow sound rides the ceremony clip's own keyframes, and a
/// `bInstant` path plays no clip — so snaps are silent (director-verified on the ref).
/// `sound::sheathe` rings each wielded hand off it.
/// `sound::sheathe` rings [`Self::slot`]'s item off it, so a two-movement melee→ranged toggle
/// rings the swords going away and then, a clip later, the bow coming out.
#[derive(Message, Clone, Copy)]
pub(crate) struct SheathSwapMessage {
pub(crate) entity: Entity,
/// `true` = the transition drew the weapons (sheath state nonzero); `false` = stowed them.
/// The held slot whose model just moved: 0 mainhand · 1 offhand · 2 ranged.
pub(crate) slot: u8,
/// `true` = this arm drew its weapon; `false` = it put it away.
pub(crate) drawing: bool,
}
@@ -122,13 +297,48 @@ pub(super) fn load_anim_data(mut commands: Commands, assets: Option<Res<WorldAss
}
}
/// Arm an [`ArmLeg`] as a live masked overlay on one arm's subtree: the clip resolved through the
/// model's own baked fallback first (decision 0082), played over whatever the body is doing. `None`
/// when the model has no such clip or no arm mask — that arm then simply snaps.
fn arm_leg(
arm: usize,
leg: ArmLeg,
state: u8,
player: &mut AnimationPlayer,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
) -> Option<SheathArm> {
let c = find_resolved(anims, leg.clip, catalog)?;
let arm_nodes = c.arm_nodes?;
let node = if arm == ARM_RIGHT {
arm_nodes.0
} else {
arm_nodes.1
};
let active = player.play(node);
active.replay();
active.set_weight(SHEATH_OVERLAY_WEIGHT);
Some(SheathArm {
node,
swap_at: c
.events
.iter()
.find(|e| matches!(&e.ident, b"$SHL" | b"$SHR"))
.map(|e| e.time)
.unwrap_or(c.duration * 0.5),
drawing: leg.drawing,
// The moving slot is the ranged one whenever the leg's own state is ranged; otherwise the
// arm *is* the slot (mainhand right, offhand left).
slot: if state == 2 { 2 } else { arm as u8 },
crossed: false,
})
}
/// Start the draw/stow **ceremony** for a [`SheathRequest`] that asked for one (the manual
/// toggle's `bInstant = 0`): up to two per-arm masked overlays (mainhand → right arm, offhand →
/// left, each by its item's stow family — the byte-verified `& 0x88` pick, resolved through the
/// model's own baked fallback first — decision 0082), plus the [`VisualSheath`] pin holding the
/// *pre-transition* placement (`old_state`) until a clip crosses the authored `$SHL`/`$SHR` swap
/// moment. No playable clips (no weapons, no arm masks — including after resolution) → no overlay,
/// and the transition degrades to the snap.
/// toggle's `bInstant = 0`) — **phase 1** ([`sheath_phase1`], the client's `0x611b60`): each arm
/// gets at most one masked overlay, and [`VisualSheath`] holds that arm's pre-swap placement until
/// its clip reaches the authored `$SHL`/`$SHR` moment. An arm whose leg has no playable clip
/// snaps; no arm playing anything at all → no ceremony, and the whole transition snaps.
#[allow(clippy::too_many_arguments)]
pub(super) fn start_sheath_ceremony(
commands: &mut Commands,
@@ -138,48 +348,99 @@ pub(super) fn start_sheath_ceremony(
anims: &ModelAnimations,
wielded: Option<&Wielded>,
old_state: u8,
new_state: u8,
catalog: Option<&AnimDataCatalog>,
) {
let w = wielded.copied().unwrap_or_default();
let arm_ids = [
w.main.map(|_| select::sheath_clip(w.main_sheath)),
w.off.map(|_| select::sheath_clip(w.off_sheath)),
];
let mut nodes = [None, None];
let mut swap_at = f32::MAX;
for (arm, id) in arm_ids.iter().enumerate() {
let Some(c) = id.and_then(|id| find_resolved(anims, id, catalog)) else {
continue;
};
let Some(arm_nodes) = c.arm_nodes else {
continue;
};
let node = if arm == 0 { arm_nodes.0 } else { arm_nodes.1 };
let active = player.play(node);
active.replay();
active.set_weight(SHEATH_OVERLAY_WEIGHT);
nodes[arm] = Some(node);
swap_at = swap_at.min(
c.events
.iter()
.find(|e| matches!(&e.ident, b"$SHL" | b"$SHR"))
.map(|e| e.time)
.unwrap_or(c.duration * 0.5),
);
let legs = sheath_phase1(old_state, new_state, &w);
let mut arms: [Option<SheathArm>; 2] = [None, None];
for (arm, leg) in legs.into_iter().enumerate() {
let Some(leg) = leg else { continue };
// A stow leg moves what the OLD state had in that hand; a draw leg, what the new one will.
let state = if leg.drawing { new_state } else { old_state };
arms[arm] = arm_leg(arm, leg, state, player, anims, catalog);
}
if nodes.iter().any(Option::is_some) {
drv.sheath_swap = Some(SheathSwap {
nodes,
swap_at,
unpinned: false,
});
commands.entity(entity).insert(VisualSheath(old_state));
if arms.iter().any(Option::is_some) {
let swap = SheathSwap {
arms,
prev: old_state,
};
commands.entity(entity).insert(VisualSheath([
swap.arm_state(0, new_state),
swap.arm_state(1, new_state),
]));
drv.sheath_swap = Some(swap);
}
}
/// Advance a ceremony in flight: cross each arm's swap point (moving that arm's weapon and ringing
/// it), and — the client's **phase 2** — when a *stow* clip finishes, hand that arm to
/// [`sheath_phase2`] so it can reach for the new weapon. The ceremony ends when every arm has run
/// out of legs, at which point [`VisualSheath`] is dropped and the resolver falls back to the
/// committed state.
#[allow(clippy::too_many_arguments)]
pub(super) fn advance_sheath_ceremony(
commands: &mut Commands,
entity: Entity,
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
anims: &ModelAnimations,
wielded: Option<&Wielded>,
cur: u8,
catalog: Option<&AnimDataCatalog>,
swaps: &mut MessageWriter<SheathSwapMessage>,
) {
let Some(mut swap) = drv.sheath_swap.take() else {
return;
};
let w = wielded.copied().unwrap_or_default();
let prev = swap.prev;
for arm in [ARM_RIGHT, ARM_LEFT] {
let Some(leg) = &mut swap.arms[arm] else {
continue;
};
// An overlay that vanished under us (a model rebuild) counts as finished — never leave a
// weapon pinned to a placement nothing is animating towards.
let (crossed, finished) = match player.animation(leg.node) {
Some(a) => (a.seek_time() >= leg.swap_at, a.is_finished()),
None => (true, true),
};
if crossed && !leg.crossed {
leg.crossed = true;
swaps.write(SheathSwapMessage {
entity,
slot: leg.slot,
drawing: leg.drawing,
});
}
if !finished {
continue;
}
let (node, drawing) = (leg.node, leg.drawing);
player.stop(node);
// **Phase 2** (`0x5fc920` @ `0x5fca8c`/`0x5fcaa1`): a finished *stow* — the phase bit still
// clear — hands this arm to its drawer. A finished draw (bit set) falls through to the
// arm's idle, exactly as the ref releases the sub-sequence with `0x7121a0(subSeq, -1, …)`.
swap.arms[arm] = (!drawing)
.then(|| sheath_phase2(arm, prev, cur, &w))
.flatten()
.and_then(|next| arm_leg(arm, next, cur, player, anims, catalog));
}
if swap.arms.iter().any(Option::is_some) {
commands.entity(entity).insert(VisualSheath([
swap.arm_state(0, cur),
swap.arm_state(1, cur),
]));
drv.sheath_swap = Some(swap);
} else {
commands.entity(entity).remove::<VisualSheath>();
}
}
#[cfg(test)]
mod tests {
use super::toggle_sheath_next;
use super::*;
/// The `ToggleSheath` cycle exactly as the bytes branch (`0x5eb642`–`0x5eb6a8`): three states,
/// each leg gated on what is actually worn. The director's report — "Z should take out swords
@@ -211,4 +472,103 @@ mod tests {
assert_eq!(toggle_sheath_next(1, EMPTY), Some(0));
assert_eq!(toggle_sheath_next(2, EMPTY), Some(0));
}
/// A sword-and-board warrior carrying a bow — the director's loadout. Sheath types: sword on
/// the hip (3 ⇒ HipSheath 90), shield on the back (4 ⇒ Sheath 89), bow on the back (1 ⇒ 89).
fn warrior() -> Wielded {
Wielded {
main: Some((2, 7)), // one-handed sword
off: Some((4, 6)), // shield
ranged: Some((2, 2)), // bow
main_sheath: 3,
off_sheath: 4,
ranged_sheath: 1,
ranged_inv: 0x0f, // INVTYPE_RANGED — the left arm
materials: [1, 6, 2], // metal sword, plate shield, wood bow (real 5875 values)
}
}
const STOW_HIP: ArmLeg = ArmLeg {
clip: 90,
drawing: false,
};
const STOW_BACK: ArmLeg = ArmLeg {
clip: 89,
drawing: false,
};
const DRAW_HIP: ArmLeg = ArmLeg {
clip: 90,
drawing: true,
};
const DRAW_BACK: ArmLeg = ArmLeg {
clip: 89,
drawing: true,
};
/// **The director's report, as a table.** Both hands full: a melee → ranged toggle puts the
/// sword and shield away and draws *nothing* — the bow waits for phase 2, which fires when
/// each stow clip finishes and reaches for it on the left arm alone. Byte-read at `0x611b60`
/// (phase 1) and `0x5fc920` @ `0x5fca8c`/`0x5fcaa1` (phase 2).
#[test]
fn a_full_pair_of_hands_stows_first_and_only_then_reaches_for_the_bow() {
let w = warrior();
// Phase 1 of melee → ranged: two stows, no draw anywhere. This is movement one.
assert_eq!(sheath_phase1(1, 2, &w), [Some(STOW_HIP), Some(STOW_BACK)]);
// Phase 2, once those finish: the right arm has nothing to draw for a bow and is released
// to its idle; the left reaches over the shoulder. Movement two.
assert_eq!(sheath_phase2(ARM_RIGHT, 1, 2, &w), None);
assert_eq!(sheath_phase2(ARM_LEFT, 1, 2, &w), Some(DRAW_BACK));
// …and back the same way (the director's "that's how it puts it back too"): the bow is
// stowed with a LITERAL 89 (`0x611c8c`) rather than its own `& 0x88` pick, and the
// mainhand — whose hand is free — draws right away, while the shield waits for phase 2.
assert_eq!(sheath_phase1(2, 1, &w), [Some(DRAW_HIP), Some(STOW_BACK)]);
assert_eq!(sheath_phase2(ARM_LEFT, 2, 1, &w), Some(DRAW_BACK));
}
/// The one-movement cases, which must NOT grow a second phase: every draw out of the stowed
/// state (`0x611b60` → `0x611a20`, both bits set) and every stow into it.
#[test]
fn drawing_from_stowed_is_a_single_movement() {
let w = warrior();
// 0 → 1 draws both hands at once; 0 → 2 puts the bow in the left and leaves the right out.
assert_eq!(sheath_phase1(0, 1, &w), [Some(DRAW_HIP), Some(DRAW_BACK)]);
assert_eq!(sheath_phase1(0, 2, &w), [None, Some(DRAW_BACK)]);
// The drawers' own `if (PREV == 0) return 0` (`0x6118a5`/`0x611965`) — nothing deferred.
for arm in [ARM_RIGHT, ARM_LEFT] {
assert_eq!(sheath_phase2(arm, 0, 1, &w), None);
assert_eq!(sheath_phase2(arm, 0, 2, &w), None);
}
// Stowing: phase 1 puts both away, and phase 2 finds nothing to draw for state 0.
assert_eq!(sheath_phase1(1, 0, &w), [Some(STOW_HIP), Some(STOW_BACK)]);
assert_eq!(sheath_phase1(2, 0, &w), [None, Some(STOW_BACK)]);
for arm in [ARM_RIGHT, ARM_LEFT] {
assert_eq!(sheath_phase2(arm, 1, 0, &w), None);
assert_eq!(sheath_phase2(arm, 2, 0, &w), None);
}
}
/// A lone mainhand leaves the other arm free, so its draw rides along with the stow — the ref
/// falls straight through `0x611b60`'s `if (oh != 0) … return` into `FUN_00611960`. One
/// movement, and the reason the two-phase shape only shows up with both hands occupied.
#[test]
fn a_free_hand_draws_without_waiting() {
let w = Wielded {
off: None,
..warrior()
};
assert_eq!(sheath_phase1(1, 2, &w), [Some(STOW_HIP), Some(DRAW_BACK)]);
// A gun sits on the RIGHT arm (`[+3] == 0x1a`), so the same loadout inverts: the mainhand
// must be put away first, and the gun's draw is what defers.
let g = Wielded {
ranged_inv: 0x1a,
..w
};
assert_eq!(sheath_phase1(1, 2, &g), [Some(STOW_HIP), None]);
assert_eq!(sheath_phase2(ARM_RIGHT, 1, 2, &g), Some(DRAW_BACK));
}
}
+36
View File
@@ -5,6 +5,14 @@
//! report ("it snaps when I land") can be read across all of them. Each file opens with a
//! `# t0=<unix epoch>` header so **two clients' traces align with each other**, which is what a
//! sender-vs-observer question needs. Costs one `OnceLock` read per call when the env var is unset.
//!
//! **`WOW_MOVE_TRACE_TAGS="move,snd,in"` narrows it to those tags** (unset = everything). This is
//! not tidiness: every line is an unbuffered `write` under one global mutex on the main thread, so a
//! busy tag distorts the run it is meant to measure. A 600-yd fall probe (decision 0880) wrote
//! ~4,200 lines/s, nearly all of them `card`, and the `move` line's own `t=` then carried whatever
//! the queue ahead of it cost — frames read back as 0.011 s with 2.86 yd of travel, or 0.040 s with
//! 0.96 yd, against a physics integration that was exact. A trace whose clock cannot be trusted for
//! per-frame timing is half an instrument; filtering to the tags the question needs gives it back.
use std::fs::File;
use std::io::Write;
@@ -40,14 +48,42 @@ fn sink() -> Option<&'static Mutex<Sink>> {
.as_ref()
}
/// The optional tag allow-list from `WOW_MOVE_TRACE_TAGS`; `None` (unset or empty) = keep everything.
static TAGS: OnceLock<Option<Vec<String>>> = OnceLock::new();
/// Whether `tag` survives the allow-list. Checked **before** the mutex, so a filtered-out tag costs
/// a slice compare rather than a lock and a syscall — which is the whole point (see the module note).
fn tag_allowed(tag: &str) -> bool {
TAGS.get_or_init(|| {
let raw = std::env::var("WOW_MOVE_TRACE_TAGS").ok()?;
let allow: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(!allow.is_empty()).then_some(allow)
})
.as_ref()
.is_none_or(|allow| allow.iter().any(|t| t == tag))
}
/// Whether the trace is enabled — lets callers skip building their line when it's off.
pub(crate) fn enabled() -> bool {
sink().is_some()
}
/// Whether this *tag* is being written — [`enabled`] plus the allow-list, for a caller whose line is
/// expensive to build (a per-frame `format!` over a hot loop) and which is filtered out anyway.
pub(crate) fn enabled_for(tag: &str) -> bool {
enabled() && tag_allowed(tag)
}
/// Append one tagged line, stamped with the sink's shared clock.
pub(crate) fn line(tag: &str, msg: &str) {
let Some(sink) = sink() else { return };
if !tag_allowed(tag) {
return;
}
let Ok(mut s) = sink.lock() else { return };
let t = s.t0.elapsed().as_secs_f32();
let _ = writeln!(s.out, "t={t:9.3} {tag:4} {msg}");
@@ -185,16 +185,21 @@ pub(in crate::entities) fn resolve_equipment(
commands.entity(entity).insert(eq);
}
}
// The *visual* sheath state: held at the pre-transition value while the draw/stow overlay
// plays (the weapon swaps hands at the animation's authored $SHL/$SHR moment, not at the
// byte change — [`VisualSheath`]); else the anim layer's **client-side committed state**
// (the setter/reconcile cache, decision 0080 — the descriptor byte plus the policy's
// forces); else, before the driver first runs, the raw descriptor byte.
let unit_sheath = visual_sheath
.map(|v| v.0)
.or_else(|| driver.and_then(|d| d.sheath_state()))
// The settled sheath state: the anim layer's **client-side committed state** (the
// setter/reconcile cache, decision 0080 — the descriptor byte plus the policy's forces);
// else, before the driver first runs, the raw descriptor byte.
let committed = driver
.and_then(|d| d.sheath_state())
.or_else(|| s.unit_sheath_state())
.unwrap_or(0);
// …and the *visual* one governing a given slot's placement, which during a draw/stow
// ceremony is **per arm** ([`VisualSheath`]): each hand's weapon moves at its own clip's
// authored $SHL/$SHR moment, not at the byte change. A melee → ranged toggle therefore
// has the sword already on the back while the bow is still on its way to the other hand —
// the ceremony's two movements (`creature_anim::sheath`).
let sheath_of = |slot: usize, inv_type: u32| {
visual_sheath.map_or(committed, |v| v.for_slot(slot, inv_type))
};
// A player wearing a NON-character display (druid form, GM morph — decision 0695)
// attaches no equipment sub-models at all: the reference's held/helm/shoulder attach
// lives on the CCharacterComponent (`0x47a0c0`, wow-re charactermodel node), which only
@@ -214,15 +219,15 @@ pub(in crate::entities) fn resolve_equipment(
let mut wielded = Wielded::default();
let mut ranged_inv_type = None;
for slot in 0..HELD_SLOTS {
// (display id, inventory type, item sheath type, item class, item subclass) per slot.
let resolved: Option<(u32, u32, u8, u8, u8)> = match net_entity.kind {
// (display id, inventory type, item sheath type, class, subclass, material) per slot.
let resolved: Option<(u32, u32, u8, u8, u8, u8)> = match net_entity.kind {
EntityKind::Unit => {
let display = s.unit_virtual_item_display(slot as u8).filter(|d| *d != 0);
display.map(|d| {
let (class, subclass, _, inv) =
let (class, subclass, material, inv) =
s.unit_virtual_item_info(slot as u8).unwrap_or((0, 0, 0, 0));
let sheath = s.unit_virtual_item_sheath(slot as u8).unwrap_or(0);
(d, inv as u32, sheath, class, subclass)
(d, inv as u32, sheath, class, subclass, material)
})
}
EntityKind::Player => s
@@ -237,13 +242,17 @@ pub(in crate::entities) fn resolve_equipment(
t.sheath as u8,
t.class as u8,
t.subclass as u8,
t.material as u8,
)
}),
_ => None,
};
let Some((display, inv_type, item_sheath, class, subclass)) = resolved else {
let Some((display, inv_type, item_sheath, class, subclass, material)) = resolved else {
continue;
};
// The item's Material — the draw/stow sound's only key (decision 0882). Both wire
// sources carry it; neither is a guess.
wielded.materials[slot] = material;
// The wielded weapon-class pair (decision 0073's swing/ready selectors) — what's *in*
// the hand, independent of whether its model is displayed (a sheath-less item still
// swings with its own class). The mainhand's sheath type picks the draw/stow one-shot
@@ -262,6 +271,8 @@ pub(in crate::entities) fn resolve_equipment(
// nocked ammo's attach point below.
2 => {
wielded.ranged = Some((class, subclass));
wielded.ranged_sheath = item_sheath;
wielded.ranged_inv = inv_type;
ranged_inv_type = Some(inv_type);
}
_ => {}
@@ -269,7 +280,8 @@ pub(in crate::entities) fn resolve_equipment(
if !char_component {
continue; // wielded resolved; the model never attaches on a non-character body
}
let Some(attach) = placement(slot, inv_type, item_sheath, unit_sheath) else {
let Some(attach) = placement(slot, inv_type, item_sheath, sheath_of(slot, inv_type))
else {
continue;
};
let kind = if inv_type == 14 {
@@ -343,7 +355,14 @@ pub(in crate::entities) fn resolve_equipment(
// transform override; no cloak conflict). Self-only by construction, exactly like the
// client: bag slots are never replicated in 1.12, so a remote player's scan finds
// nothing (§H1 — a two-client capture would be the clean confirmation).
if net_entity.kind == EntityKind::Player && char_component && unit_sheath == 2 {
// Timed off the RANGED slot's own visual state, so the quiver arrives with the bow it
// feeds. (Named deviation: the ref attaches it at the *start* of the ranged draw — the
// `0x611e10(1)` call inside each drawer — where ours waits for that clip's $SHL. Same
// clip, a few hundred ms apart, and the two are never seen separately.)
if net_entity.kind == EntityKind::Player
&& char_component
&& sheath_of(2, ranged_inv_type.unwrap_or(0)) == 2
{
let mut quiver_display = None;
for bag in 19u8..23 {
let entry = s
+6
View File
@@ -35,6 +35,11 @@ pub(crate) struct HeldTemplate {
pub(crate) sheath: u32,
pub(crate) class: u32,
pub(crate) subclass: u32,
/// `Material` — the item's `Material.dbc` id (1 metal · 2 wood · 5 chain · 6 plate · 7 cloth ·
/// 8 leather · 0 undefined). On the wire in `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, and the **only**
/// input to the draw/stow sound pick: `SheatheSoundLookups` carries one row per weapon subclass
/// per material, and every row of a material agrees — the subclass is inert (decision 0882).
pub(crate) material: u32,
}
/// The item stores: instances by guid, templates by entry (+ the in-flight ask-once set).
@@ -133,6 +138,7 @@ impl Items {
sheath: i.sheath,
class: i.class,
subclass: i.subclass,
material: i.material,
})
}
+6
View File
@@ -119,6 +119,7 @@ mod ui_loot;
mod ui_loot_roll;
mod ui_mail;
mod ui_merchant;
mod ui_mirror;
mod ui_net;
mod ui_party;
mod ui_pass;
@@ -200,6 +201,7 @@ use ui_loot::UiLootPlugin;
use ui_loot_roll::UiLootRollPlugin;
use ui_mail::UiMailPlugin;
use ui_merchant::UiMerchantPlugin;
use ui_mirror::UiMirrorPlugin;
use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
@@ -647,6 +649,10 @@ fn main() -> AppExit {
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
.add_plugins(UiCastPlugin)
// The breath / fatigue bars (decision 0874): server-authoritative mirror timers off the
// wire into the transcribed MirrorTimer1/2/3 frames. Beside the cast bar it shares its
// feed→drain shape (and its art: the same UI-CastingBar-Border chrome).
.add_plugins(UiMirrorPlugin)
// Floating combat text (decision 0137 phase 2): the WORLDTEXTSTRING law — world-anchored
// damage numbers/outcome words projected into the UI quad pass each frame.
.add_plugins(combat_text::CombatTextPlugin)
+17
View File
@@ -145,6 +145,9 @@ pub(super) fn apply_net_updates(
// Either absent if its DBC failed to load.
Option<Res<crate::area::AreaTableRes>>,
Option<Res<crate::sound::ExplorationSounds>>,
// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges,
// drained into the FrameXML bars by `crate::ui_mirror`.
ResMut<crate::ui_mirror::MirrorTimerFeed>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
@@ -285,6 +288,7 @@ pub(super) fn apply_net_updates(
mut logout,
area_table,
exploration_sounds,
mut mirror_timers,
),
) = caches;
let (
@@ -761,6 +765,19 @@ pub(super) fn apply_net_updates(
SessionEvent::DuelCountdown { seconds } => {
crate::ui_duel::apply::countdown(&mut duel, seconds);
}
// ── The mirror timers (decision 0874): breath / fatigue / feign-death. Pure queue
// arms — every meaning (which bar, what colour, what caption, how fast it drains)
// is resolved at the UI seam in `ui_mirror`, and the countdown itself is the
// FrameXML's own OnUpdate integration ──────────────────────────────────────────────
SessionEvent::MirrorTimerStart(start) => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Start(start)),
SessionEvent::MirrorTimerPause { kind, paused } => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Pause { kind, paused }),
SessionEvent::MirrorTimerStop { kind } => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Stop { kind }),
// ── The social family (decision 0668): the friend/ignore lists, the `/who`
// answer, and the result codes that print their own chat lines. The lines and the
// Era events fire off the mirror in `ui_social::feed_social` — every one of them
+16 -27
View File
@@ -5,7 +5,7 @@
use std::time::{Duration, Instant};
use benilla_protocol::messages::{ActionButton, SpellCooldown, ACTION_KIND_SPELL};
use benilla_protocol::messages::{ActionButton, SpellCooldown};
use bevy::prelude::*;
use crate::cooldowns::Cooldowns;
@@ -57,19 +57,17 @@ pub(super) fn learned_spell(spell_id: u32, actions: &mut PlayerActions) {
}
}
/// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old in the book **and on the action
/// bar** (decision 0237). The server sends no fresh `SMSG_ACTION_BUTTONS` for this (VERIFIED vmangos
/// `Player::learnSpell` — the `supercededOld` path touches only the spell store), so the client owns
/// the bar swap: every spell button pointing at the old rank re-points at the new one.
/// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old **in the book** (decision
/// 0237). The server sends no fresh `SMSG_ACTION_BUTTONS` for this (VERIFIED vmangos
/// `Player::learnSpell` — the `supercededOld` path touches only the spell store), so the bar has to
/// follow too; it does, from the book, in `ui_action::ranks` (decision 0883). Re-pointing buttons
/// *here* as well would be a second, weaker copy of that law — weaker because this packet doesn't
/// arrive at all when the rank was gained while the character was loading (vmangos suppresses it
/// with `IsInWorld()`), which is exactly the case that shipped a dead rank-1 button.
pub(super) fn superceded_spell(old_spell_id: u32, new_spell_id: u32, actions: &mut PlayerActions) {
debug!("net: superceded spell {old_spell_id} -> {new_spell_id}");
actions.spells.remove(&old_spell_id);
actions.spells.insert(new_spell_id);
for button in actions.buttons.values_mut() {
if button.kind == ACTION_KIND_SPELL && button.action == old_spell_id {
button.action = new_spell_id;
}
}
actions.dirty = true;
}
@@ -677,7 +675,7 @@ pub(super) fn aura_duration(
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::ACTION_KIND_MACRO;
use benilla_protocol::messages::ACTION_KIND_SPELL;
#[test]
fn learned_spell_adds_to_the_book_once() {
@@ -694,12 +692,12 @@ mod tests {
);
}
/// A rank-up moves the **book** and marks the store dirty; the bar is not touched here — the
/// one rank law lives in `ui_action::ranks`, which the dirty flag then runs (decision 0883).
#[test]
fn superceded_spell_swaps_the_book_and_only_matching_spell_buttons() {
fn superceded_spell_swaps_the_book_and_leaves_the_bar_to_the_rank_pass() {
let mut actions = PlayerActions::default();
actions.spells.insert(78); // rank 1, known
// Rank 1 sits on the bar in slot 0; slot 1 is a *macro* whose id
// collides with the old rank and must NOT be rewritten.
actions.spells.insert(78); // Heroic Strike rank 1, known
actions.buttons.insert(
0,
ActionButton {
@@ -708,14 +706,6 @@ mod tests {
kind: ACTION_KIND_SPELL,
},
);
actions.buttons.insert(
1,
ActionButton {
slot: 1,
action: 78,
kind: ACTION_KIND_MACRO,
},
);
superceded_spell(78, 284, &mut actions);
@@ -727,12 +717,11 @@ mod tests {
actions.spells.contains(&284),
"the new rank enters the book"
);
assert_eq!(actions.buttons[&0].action, 284, "the spell button rank-ups");
assert_eq!(
actions.buttons[&1].action, 78,
"the id-colliding macro is untouched"
actions.buttons[&0].action, 78,
"the bar follows from the book, not from here"
);
assert!(actions.dirty);
assert!(actions.dirty, "…and `dirty` is what makes it follow");
}
/// The regression the live `WOW_CAST_TRACE` caught: a triggered proc (Frost Armor's Chilled
+95 -13
View File
@@ -72,13 +72,47 @@ use state::{
// resident world is still the departed map's (0710).
pub(crate) use state::{
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, FEATHER_TERMINAL_VELOCITY,
GRAVITY, HOVER_HEIGHT, SETTLE_TIMEOUT, TERMINAL_VELOCITY,
GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, SETTLE_TIMEOUT, TERMINAL_VELOCITY,
};
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
/// swim marker and the footstep splash slot, which have no `Player` of their own.
pub(crate) use swim::swim_enter_depth;
/// **`UNIT_FLAG_STUNNED`** — the `UNIT_FIELD_FLAGS` bit that freezes a character's *turning*
/// (decision 0872). Not a movement flag and not an aura: the reference reads it straight off the
/// descriptor block at `[[unit+0x110]+0xa0]` (predicate `0x5145b0` — note the inverted
/// `not/shr/and` form, which a census grepping only `test …,0x40000` misses — consumed at
/// `0x514755`, which skips both the turn and pitch emitters and force-stops either in flight).
///
/// It is the **other half** of a stun. vmangos's `HandleModStun` sets this flag *and* calls
/// `SetRooted(true)`, so `SPELL_AURA_MOD_STUN` grants both: root kills translation, this kills the
/// pivot. A pure root (Frost Nova, Entangling Roots) sets only the first — which is why a rooted
/// player can still turn and a stunned one cannot, the distinction B179 was reporting.
///
/// It has a second consumer beyond the turn: the idle/fidget selectors bail on it
/// (`0x5eb4f2`/`0x5ec219`), which is what stops even the idle twitch — see
/// [`crate::creature_anim::MovementState::stunned`] (decision 0880).
pub(crate) const UNIT_FLAG_STUNNED: u32 = 0x0004_0000;
/// Ask for a **stand state** — the client's `SetStandState(newState)` (`0x5ed430`: send
/// `CMSG_STANDSTATECHANGE` + apply locally through `0x6127b0`), as a message so every path that
/// wants a posture funnels through the ONE setter in [`control`] (the [`crate::creature_anim::
/// SheathRequest`] posture, decision 0080).
///
/// Two senders today: the `X` key reads the toggle inline in [`control`], and the **posture emotes**
/// (`/sit`, `/sleep`, `/kneel`, `/stand`, `/lay`) send this — `DoEmote`'s `EmoteSpecProc == 1`
/// branch calls the same `0x5ed430` the key does (wow-re `object-layer/scratch/emote-posture-
/// gate.md` §1, decision 0881). Routing them here is what makes `/sit` sit at all: the *server*
/// does nothing for a STATE text emote (vmangos `HandleTextEmoteOpcode` breaks out of the switch
/// for SIT/SLEEP/KNEEL), so the posture is the client's own to set.
///
/// The state values are `UnitStandStateType`: 0 STAND · 1 SIT · 3 SLEEP · 8 KNEEL.
#[derive(bevy::ecs::message::Message, Clone, Copy, Debug)]
pub(crate) struct StandStateRequest {
pub(crate) state: u8,
}
/// The player/camera subsystem: spawns the camera + move/avatar resources at startup, drives the
/// third-person/free-fly controller each frame. (The cursor is the [`crate::cursor`] subsystem.)
pub(crate) struct PlayerPlugin;
@@ -108,6 +142,9 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The posture setter's queue (the `/sit` family — decision 0881; `control` is the sole
// executor, like the sheath queue).
.add_message::<StandStateRequest>()
// Land-here ([`land`]): the ask, and the re-attach when the server's teleport lands.
// Before `control` so the frame that applies the teleport is the frame that takes
// third-person control back — `control` reads `detached` after this has cleared it.
@@ -236,6 +273,9 @@ fn control(
// A `MSG_MOVE_*` the server addressed to our OWN mover (decision 0725) — a pose it wrote,
// with no handshake and no ack owed. `wire_in` snaps to it.
MessageReader<crate::net::SelfMoveMessage>,
// The posture queue (decision 0881): `/sit` and its family ask here; the X key is read
// inline below. One setter, either way.
MessageReader<StandStateRequest>,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
speed_capsule: (
@@ -366,6 +406,18 @@ fn control(
// writes `face_yaw` directly — any change is a real mouse TURN of the character (a left-drag
// orbits the camera only and never touches it).
let yaw_before_look = player.face_yaw;
// **Stunned** (`UNIT_FIELD_FLAGS & 0x40000` — decision 0872): read once here, because the very
// first thing a stun suppresses is the mouse turn below. This is a descriptor bit, NOT a
// movement flag and NOT an aura: the reference's `0x5145b0` computes `!STUNNED` straight off
// `[[unit+0x110]+0xa0]` (`not eax; shr eax,0x12; and eax,1`) and `0x514755` consumes it to skip
// the turn and pitch emitters outright.
let stunned = self_player
.single()
.ok()
.and_then(|(.., store, _, _, _, _)| {
store.map(|s| s.0.unit_flags() & UNIT_FLAG_STUNNED != 0)
})
.unwrap_or(false);
run_look_session(
&buttons,
mouse_motion,
@@ -385,6 +437,15 @@ fn control(
left_click,
right_click,
);
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
// a right-drag, coupled `face_yaw = cam.yaw`); putting the aim back leaves the camera orbiting
// a body that does not turn — which is what a stunned character looks like, and what the
// reference produces by never running the turn emitter at all. Restoring rather than gating
// inside the session keeps the approved camera path (0050/0366's right-drag coupling) untouched.
// `mouse_turned` then reads false by construction, so the seated-turn stand-up cannot fire either.
if stunned {
player.face_yaw = yaw_before_look;
}
let mouse_turned = player.face_yaw != yaw_before_look;
{
let cur = cursor_opts.bypass_change_detection();
@@ -592,7 +653,12 @@ fn control(
0
};
// A/D turn the facing when not mouse-looking (yaw increases turning left, matching mouse-left).
let turning = !mouselook && (keys_pressed(KeyCode::KeyA) || keys_pressed(KeyCode::KeyD));
// …and never while stunned: the reference skips the keyboard turn emitter `0x514f50`
// entirely (and force-stops an in-flight turn) behind the same `0x514755` gate. Killing the
// turn here is also what ends B179's *second* half — the walk animation a stunned character
// was still playing was the turn-in-place shuffle, which `gait` derives from real yaw change.
let turning =
!mouselook && !stunned && (keys_pressed(KeyCode::KeyA) || keys_pressed(KeyCode::KeyD));
// This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly
// (char and camera turn as one on the reference; director's call, closing 0050's open
// "camera follow on turn" feel item).
@@ -631,8 +697,13 @@ fn control(
-1 => dir -= move_right,
_ => {}
}
// Rooted (dead-unreleased): translation intent dies here — turning above stays live, the
// real rooted client's behavior (decision 0308 slice 1).
// **Rooted: translation intent dies here — turning above stays live.** Confirmed at the
// bytes (decisions 0866/0872) and it is *authored*, not accidental: the reference's input
// tick consults an allow-list (`0x615c71` → the byte table at `0x618054`) which blocks the
// translation command ids and **explicitly permits** the turn ids 8/9/0xa, pitch, run/walk
// and SetFacing. A character who cannot even pivot is STUNNED, a separate `UNIT_FIELD_FLAGS`
// gate handled above — and vmangos's `HandleModStun` grants both at once, which is why Ice
// Block freezes completely while Frost Nova lets you turn.
if player.modes.rooted {
dir = Vec3::ZERO;
}
@@ -652,7 +723,11 @@ fn control(
player.stand_pending = None; // the echo landed
}
let stand_state = player.stand_pending.unwrap_or(stand_byte);
let mut request_stand = None;
// The queued asks first (the `/sit` family — decision 0881), then the X key, which is the
// reference's own precedence: a queued `SetStandState` ran during the frame's message pass,
// the key is read now. The last writer wins, and every one of them lands on the single
// commit-and-send below.
let mut request_stand = net.10.read().last().map(|r| r.state);
if bare_binding(KeyCode::KeyX) {
request_stand = Some(u8::from(stand_state == 0));
}
@@ -908,13 +983,9 @@ fn control(
let fwd_axis = move_fwd * cp + Vec3::Y * sp;
let v = fwd_axis * swim_fwd + move_right * swim_side;
let dir3 = v.normalize_or_zero();
// The rest line this stroke may not rise above. **`None` while levitating** — GM flight
// is the swim regime with no liquid under it (decision 0726), so there is no waterline
// to cap against and the climb must be free; capping there would let you dive but never
// gain height. Otherwise fall back to the current feet Y if the surface query briefly
// misses (a chunk seam): the cap is then at our own depth, so the avatar just holds for
// that frame — a transient miss in real water is not the same thing as flight.
let rest_line = (!player.modes.levitating).then(|| surface_y.unwrap_or(player.pos.y));
// Whether the water owns our vertical at all this frame, and where its line sits — the
// one source both arms of the constraint read (`swim::rest_line`; `None` is GM flight).
let rest_line = swim::rest_line(&player, surface_y);
// Directional swim speed — **VERIFIED** (`0x7c4c90`'s swim arm, the §5's TU-H):
// forward or strafe-only → swim; the backward bit `0x2` → `min(swimBack, swim)` —
// byte-identical in template to the run arm's `min(runBack, run)`. Vanilla defaults
@@ -987,7 +1058,14 @@ fn control(
let now = time.elapsed_secs();
// Airborne is a walk-only concept — swimming never falls, so the body-heading / anim-flags
// logic below reads this hoisted value (false while swimming) instead of the walk branch's.
let airborne = !swimming && !held && (!grounded || jumped);
//
// **A root ends the arc outright** (decision 0880): `airborne` is our `MOVEFLAG_FALLING`,
// and `SetRoot 0x7c7340`'s second act is `StopFalling 0x7c6290`, which clears FALLING and
// FALLINGFAR together. So a root or a stun taken mid-air is not a body that lands — it is a
// body that is no longer falling, hanging where it was ([`mover::step`]'s anchor holds the
// position). Ground contact is left honest in `grounded` (the transport attach and the trace
// both want the truth); it is the *arc* that the root ends.
let airborne = !swimming && !held && !player.modes.rooted && (!grounded || jumped);
// Transport attach/detach (decision 0438 phase 2). Attach when the walkable support is a
// transport's collider — the boat's own hull, OR a deck prop's collider child (solid
// cargo, 0470): the walk resolves the support upward through the parent chain to the
@@ -1160,6 +1238,10 @@ fn control(
move_flags_now = 0;
}
}
// The two incapacitate suppressions — rooted drops the direction bits, stunned drops the
// turn bits — applied to the whole word in one place, whichever branch built it, and with
// the reference's byte trail in [`state::incapacitated_flags`] (decision 0880).
move_flags_now = state::incapacitated_flags(move_flags_now, player.modes.rooted, stunned);
// Riding a transport: the ON_TRANSPORT bit rides every packet with its local-pose tail
// (built at the send below). Set from the POST-attach state so flag and tail agree the
// very frame we board or step off (decision 0438 phase 2).
+66 -1
View File
@@ -84,7 +84,16 @@ impl Player {
new_arc,
// A landing closes the arc (→ MSG_MOVE_FALL_LAND). A same-frame relaunch keeps
// `airborne` true, so no FALL_LAND fires — the bounce streams a fresh JUMP instead.
landed: !airborne && was_airborne,
//
// **A root ends the arc without landing it** (decision 0880). `SetRoot`'s `StopFalling`
// clears FALLING wherever the body happens to be, and the reference *separately*
// suppresses the land packet while rooted (`0x602df3 test ah,0x10` gating opcode
// `0xc9`). Both halves matter here: without the suppression a stun caught mid-fall would
// report a touchdown in mid-air — a `MSG_MOVE_FALL_LAND` carrying the whole accumulated
// fall clock, which vmangos `Player::HandleFall` reads as a killing drop, plus the local
// wound grunt and dust puff of a landing that never happened. On release the fall
// restarts as a fresh arc from a zero clock, which is `ClearRoot`'s `StartFalling(0)`.
landed: !airborne && was_airborne && !self.modes.rooted,
// A step-off with no jump opcode needs its first airborne report pushed promptly so
// observers start the arc (→ an immediate heartbeat); a jump has its own JUMP opcode.
started_falling: airborne && !was_airborne && !jumped,
@@ -268,6 +277,62 @@ mod tests {
);
}
/// A root or a stun caught mid-fall **ends the arc without landing it** (decision 0880). The
/// mover holds the body where it was (its anchor), so `airborne` goes false in mid-air — and the
/// reference suppresses the land packet on exactly that state (`0x602df3`). Were it not
/// suppressed, the frame the stun lands would report a touchdown 30 yd up carrying the whole
/// accumulated fall clock, which vmangos `Player::HandleFall` reads as a killing drop, and would
/// fire the local wound grunt + dust puff for a landing that never happened.
#[test]
fn a_root_taken_mid_fall_ends_the_arc_without_landing_it() {
let mut p = take_off(100.0);
p.pos.y = 70.0;
p.advance_airborne_arc(true, false, 1.0, 100.0);
assert!(p.fall_far, "precondition: a real, far fall is in progress");
p.modes.rooted = true;
let edges = p.advance_airborne_arc(false, false, 1.2, 100.0);
assert!(!edges.landed, "a root ENDS the arc; it does not land it");
assert_eq!(p.airborne_since, None, "and the fall clock is cleared");
// The control that gives the assertion its teeth: the identical frame with no root is a
// landing. The gate is the root, not "any arc that ends".
let mut q = take_off(100.0);
q.pos.y = 70.0;
q.advance_airborne_arc(true, false, 1.0, 100.0);
assert!(
q.advance_airborne_arc(false, false, 1.2, 100.0).landed,
"an unrooted body reaching the ground still reports its landing"
);
}
/// Releasing the root in mid-air is `ClearRoot 0x7c7370`'s `StartFalling` — a **fresh** arc from
/// a zero clock, not a resumption of the one the root ate. That is what keeps the drop honest:
/// the fall damage the server assesses is measured from the release, not from before the stun.
#[test]
fn releasing_the_root_mid_air_starts_a_fresh_fall_not_a_resumption() {
let mut p = take_off(100.0);
p.pos.y = 70.0;
p.advance_airborne_arc(true, false, 1.0, 100.0);
p.modes.rooted = true;
p.advance_airborne_arc(false, false, 1.2, 100.0);
p.modes.rooted = false; // the aura expired; still nothing under us
let edges = p.advance_airborne_arc(true, false, 11.2, 70.0);
assert!(edges.new_arc, "the release begins a new arc");
assert!(
edges.started_falling,
"a bracket-less step-off — StartFalling(0), no JUMP opcode"
);
assert_eq!(p.jump_zspeed, 0.0, "launched at exactly 0, like a walk-off");
assert_eq!(
p.airborne_since,
Some(11.2),
"a fresh fall clock — the pre-root one does not carry over"
);
assert_eq!(p.fall_start_y, 70.0, "and a fresh launch height");
assert!(!p.fall_far, "the new arc starts unlatched");
}
#[test]
fn a_clean_landing_clears_the_clock_and_reports_landed() {
let mut p = take_off(100.0);
+14 -2
View File
@@ -36,6 +36,9 @@ pub(super) struct Frame {
/// The atomic step-up's committed height gain this frame (yd), when the maneuver ran
/// (decision 0209).
pub climb: Option<f32>,
/// The root's anchor ran this frame (decision 0880) — no gravity, no slide, no snap. Without it
/// on the line, a rooted hang and a genuinely stuck mover are the same column of `dy=+0.000`.
pub anchored: bool,
}
/// One `snd` line per outbound movement packet: the opcode we chose, the live move-flags it carries,
@@ -156,11 +159,20 @@ pub(super) fn frame(f: Frame) {
),
};
let climb = f.climb.map_or(String::new(), |t| format!(" climb={t:+.3}"));
let anchored = if f.anchored { " ROOTED" } else { "" };
dbg_trace::line(
"move",
&format!(
"y {:9.3} -> {:9.3} dy={:+.3} grounded={} walk={} vy={:+7.2} {}{}",
f.y_in, f.y_out, dy, f.grounded as u8, f.on_walkable as u8, f.vel_y, snap, climb
"y {:9.3} -> {:9.3} dy={:+.3} grounded={} walk={} vy={:+7.2} {}{}{}",
f.y_in,
f.y_out,
dy,
f.grounded as u8,
f.on_walkable as u8,
f.vel_y,
snap,
climb,
anchored
),
);
}
+57 -12
View File
@@ -33,9 +33,9 @@ use crate::collision::player_query_filter;
use super::{
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY,
GROUND_COS, GROUND_PROBE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO,
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
WEDGE_STILL_FRAMES,
GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH,
STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL,
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
};
/// What the step decided — read by the move-flags / wire logic that follows it in `control`.
@@ -122,6 +122,23 @@ pub(super) fn step(
// swimmer, or a genuinely airborne teleport never produces (the loading-screen-until-landing
// hang). The streamer runs every frame in every mover mode, so every mode releases the same way.
let held = player.settling;
// **Rooted: the mover is ANCHORED — nothing advances the body, in any axis** (decision 0880).
// `SetRoot 0x7c7340` is three acts, not one: set `0x1000`, `call 0x7c6290` **StopFalling**
// (`and eax,0xffff9fff` — FALLING *and* FALLINGFAR together), then wipe the direction bits
// (`and 0xffe07f00`) and re-run the basis recompute `0x7c5c20`, which with no direction bit left
// to read builds a zero horizontal velocity. With FALLING clear the mode dispatcher `0x634040`
// routes the next substep to the WALK resolver `0x6367b0` rather than the fall integrator
// `0x635b00` — **the only place gravity lives** — and the walk resolver's own head gate returns
// immediately when the substep's horizontal distance is under `2^-20`, so no down-probe, no
// step-down snap and no fall election run either. Nothing is left that could move the body.
//
// That is why a root or a stun taken **mid-air leaves you hanging exactly where it caught you**,
// and why the drop resumes only on release: `ClearRoot 0x7c7370` calls `0x7c61c0` StartFalling,
// and `0x7c61c0` is precisely the entry that refuses while rooted
// (`0x7c61d6 test dword ptr [ecx+0x40], 0x203800` — SWIMMING|FALLING|ROOT|FIXED_Z), so no fall
// can begin under a root by any path. (wow-re `moveflag-family.md` §1/§5.3,
// `step-vs-fall-election.md`.)
let anchored = !held && player.modes.rooted;
let on_floor = !held && on_walkable && player.vel_y <= 0.0;
// The wedged rest (decision 0211) stands until real ground takes over or the support
@@ -136,8 +153,9 @@ pub(super) fn step(
let grounded = on_floor || player.wedged;
let mut jumped = false;
if held {
// Frozen at the snap position with no velocity until the ground loads under us.
if held || anchored {
// Frozen: the settle's hold (no velocity until the ground loads under us) or the root's
// anchor. The mover cannot tell them apart — both mean no gravity and no carried momentum.
player.vel_y = 0.0;
player.horiz_vel = Vec3::ZERO;
} else if grounded {
@@ -162,9 +180,14 @@ pub(super) fn step(
player.vel_y = (player.vel_y - GRAVITY * dt).max(-terminal);
}
let mut air_nudged = false;
if grounded {
// The anchor owns the horizontal too — the wipe leaves the basis recompute nothing to build a
// velocity from, so a body rooted mid-jump stops dead in the air instead of coasting on its
// frozen takeoff momentum. (Both arms below are already inert under a root — the caller zeroes
// `dir`, so `moving` is false and `input_horiz` is zero — but the anchor says it itself rather
// than inheriting it from a gate three functions away.)
if grounded && !anchored {
player.horiz_vel = input_horiz;
} else if !held && moving && player.horiz_vel.length_squared() < 0.01 {
} else if !held && !anchored && moving && player.horiz_vel.length_squared() < 0.01 {
// Air control: one nudge to steer a jump that took off from a standstill (a moving jump
// keeps its momentum locked, since horiz_vel is already non-zero). The pressed direction
// *really* moves us, so it re-seeds the frozen airborne direction flags.
@@ -177,7 +200,7 @@ pub(super) fn step(
// snap — the same code every remote mover's dead-reckon runs. Held and airborne/jumping
// frames keep their own slide here: no step-up, no snap, and gravity in the velocity.
let (mut climb, mut snap_probe) = (None, None);
if !held && grounded && !jumped {
if !held && !anchored && grounded && !jumped {
let g = grounded_step(
ms,
capsule,
@@ -194,9 +217,9 @@ pub(super) fn step(
ground_entity = Some(e);
}
} else {
// Held: zero velocity (no move) — `held` already zeroed both terms, but say it outright.
// Jumping/airborne: gravity carries the arc.
let velocity = if held {
// Held or anchored: zero velocity (no move) — both already zeroed the two terms, but say it
// outright. Jumping/airborne: gravity carries the arc.
let velocity = if held || anchored {
Vec3::ZERO
} else {
player.horiz_vel + Vec3::Y * player.vel_y
@@ -214,6 +237,7 @@ pub(super) fn step(
// growing) catches the funnel's pinch-in as it happens — 0211's absolute-stillness test
// waited out the decelerating millimeter creep, a visible hang in the falling pose.
if !held
&& !anchored
&& !grounded
&& !jumped
&& player.vel_y < -WEDGE_MIN_FALL
@@ -240,6 +264,22 @@ pub(super) fn step(
// and the wire sees a normal landing (`MSG_MOVE_FALL_LAND`) this frame, not next.
let mut grounded = grounded || player.wedged;
// **The hover climb** (decision 0872): the snap above can only lower the body, so the *rise* to
// the 1.0-yd clearance is this separate rate-limited pass — the reference's second writer at
// `0x636fa1`–`0x6370f1`, which climbs toward the same clearance at [`HOVER_CLIMB_RATE`]. Without
// it the grant reads as an instant pop; with it the body floats up over ~0.14 s.
// (…and never while anchored: the climb is the walk resolver's own second pass, so the rooted
// mover's stationary early-return skips it exactly like the snap above.)
if hover_offset > 0.0 && !held && !anchored {
if let Some(h) = probe_down(center, HOVER_HEIGHT + CAPSULE_HEIGHT) {
let clearance = h.distance;
if clearance < HOVER_HEIGHT {
center.y += (HOVER_HEIGHT - clearance).min(HOVER_CLIMB_RATE * dt);
player.vel_y = player.vel_y.max(0.0); // climbing, not falling
}
}
}
// **Water walking: the liquid surface IS the floor** (decision 0866). `water_floor` is the
// surface Y the caller resolved, and it is `Some` only while the mode is granted AND we are not
// already swimming — the reference's own gate, read at `0x631617` (`test eax,0x200000; jne`)
@@ -266,6 +306,7 @@ pub(super) fn step(
vel_y: player.vel_y,
snap: snap_probe,
climb,
anchored,
});
Outcome {
@@ -398,7 +439,11 @@ pub(crate) fn grounded_step(
let snap = Some((reach, hit.as_ref().map(|h| (h.distance, h.normal1.y))));
let mut ground = None;
if let Some(h) = hit.filter(|h| h.normal1.y >= GROUND_COS) {
slid.y -= h.distance - surface_offset;
// `max(…, 0)` is the reference's, and it is the difference between a float and a pop: the
// snap **only ever descends** (`0x636e8d`–`0x636e9e` skips the write when `L − 1.0 < 0`), so
// a hovering body that is already within its clearance is left where it is rather than
// yanked up to it. The rise to clearance is a separate rate-limited climb — see [`step`].
slid.y -= (h.distance - surface_offset).max(0.0);
ground = Some(h.entity);
}
GroundedStep {
+160 -8
View File
@@ -86,11 +86,17 @@ pub(crate) const TERMINAL_VELOCITY: f32 = 60.148_003;
/// it is matching this clamp.
pub(crate) const FEATHER_TERMINAL_VELOCITY: f32 = 7.0;
/// **How far above the ground a hovering body rests** (yd) — the whole of what Hover does. The
/// reference's WALK resolver `0x6367b0` adds `[0x7ff9d8]` = 1.0 to its surface offset iff
/// `MOVEFLAG_HOVER` (`0x40000000`) is set, and the step-down reach widens by the same yard
/// (`0x633e35`). VERIFIED — wow-re `system/collision/collision.md` ("0x40000000 is HOVER, not a wade
/// bit") + `scratch/step-vs-fall-election.md`.
/// reference extends the walk resolver's down-probe by `[0x7ff9d8]` = 1.0 (`0x636dd2`) and then
/// subtracts it back out of the snap, landing the body exactly a yard clear. VERIFIED — wow-re
/// `scratch/moveflag-family.md` §4, which settled it as a **direct write to `CMovement.pos.z`**
/// rather than a probe widening, closing `step-vs-fall-election.md`'s open bit-identity handoff.
pub(crate) const HOVER_HEIGHT: f32 = 1.0;
/// **How fast a hovering body rises to that clearance** (yd/s). The snap itself can only *lower* the
/// body — `pos.z −= max(L − 1.0, 0)` (`0x636e52`–`0x636ea9`) skips its write entirely when the floor
/// is already within a yard — so the rise is a second, rate-limited pass at `0x636fa1`–`0x6370f1`
/// whose rate comes from `0x7c61b0` returning `[0x87d898]`: the very same 7.0 the feather-fall clamp
/// reads. Without it a granted hover reads as an instant pop instead of a float.
pub(crate) const HOVER_CLIMB_RATE: f32 = 7.0;
/// Standability gate: a surface is walkable iff its normal is within ~50° of straight up (cos 50° —
/// the vanilla threshold). Steeper than this you can't climb and you slide back down.
pub(super) const GROUND_COS: f32 = 0.642_788;
@@ -220,9 +226,12 @@ pub(super) struct MoveSpeed {
pub(crate) struct MoveModes {
/// The server rooted our mover (`SMSG_FORCE_MOVE_ROOT` — death until release, and every root or
/// stun; decision 0308). Translation input and jumps are dead — the faithful "can't move between
/// death and release" — but **turning stays live**: the reference's `StartTurn 0x7c6c50` has no
/// root gate at all, and `SetRoot 0x7c7340` clears the direction bits exactly once, at apply. A
/// character who cannot even turn is *stunned*, which is a different mechanism.
/// death and release" — but **turning stays live, and by design rather than by omission**
/// (corrected by decision 0872): the reference's input tick consults an allow-list
/// (`0x615c71` → the byte table at `0x618054`) that blocks the translation command ids and
/// **explicitly permits** turn, pitch, run/walk and SetFacing. `SetRoot 0x7c7340` additionally
/// clears the direction bits once, at apply. A character who cannot even pivot is *stunned* —
/// a separate `UNIT_FIELD_FLAGS` gate ([`crate::player::UNIT_FLAG_STUNNED`]).
pub(crate) rooted: bool,
/// Water-walking (`SMSG_MOVE_WATER_WALK`, `SPELL_AURA_WATER_WALK` — Water Walking, Levitate, and
/// the ghost form): the liquid surface counts as walkable ground, so we stand on it instead of
@@ -309,6 +318,46 @@ impl MoveModes {
}
}
/// **The two incapacitate suppressions, applied to a freshly built move-flag word** (decision 0880)
/// — the last step of [`super::control`]'s per-frame rebuild, before the word drives the animation
/// and goes on the wire.
///
/// The reference never needs this: its `[cmov+0x40]` is written *only* by emitters that already
/// refuse, so the bits are absent by construction. Ours rebuilds the word from raw input every
/// frame, so a suppression not re-applied here streams motion the body is not performing (decision
/// 0056's law) and feeds the same lie to the anim layer.
///
/// - **Rooted → no direction bits.** The input tick's allow-list (`0x615c71` → the byte table at
/// `0x618054`) DROPS command ids 0–5 — StartMove/StopMove/StartStrafe — while `0x1000` is set, so
/// a key pressed under a root never reaches `0x7c6ae0`/`0x7c6c50` and never sets a bit; `SetRoot
/// 0x7c7340`'s own `and 0xffe07f00` wipes whatever was set, at apply. With the low nibble clear
/// the movement-anim resolver `0x5fd100` takes its not-moving exit at `5fd10c test al,0xf` before
/// it can reach any locomotion id — **that** is why a rooted character shows no walk/run
/// animation, and why ours was still showing one: it kept building FORWARD out of the raw axis
/// while the mover ignored it.
/// - **Stunned → no turn bits.** `0x514755` skips the turn emitter `0x514f50` outright and
/// force-stops one already in flight, so `0x7c6d90 StartTurn` never runs. Decision 0872 stopped
/// the *rotation* (`control` restores the aim) but left the bits streaming, which still told every
/// observer to play the turn-in-place shuffle.
///
/// They are keyed on different state on purpose, exactly as the reference has them: a pure root
/// (Frost Nova, Entangling Roots) still pivots — turn is on the allow-list *deliberately* — and only
/// `UNIT_FLAG_STUNNED` takes the pivot away (wow-re `unit-flags-movement-gates.md` §5). Nothing else
/// is touched: the granted modes ride on (drop one and the server forgets it), SWIMMING survives a
/// root exactly as `0xffe07f00` preserves `0x200000`, and FALLING is already gone by the time we get
/// here — the root ended the arc ([`super::mover`]'s anchor).
pub(crate) fn incapacitated_flags(flags: u32, rooted: bool, stunned: bool) -> u32 {
use crate::creature_anim::move_flags as f;
let mut out = flags;
if rooted {
out &= !f::ANY_MOVE;
}
if stunned {
out &= !(f::TURN_LEFT | f::TURN_RIGHT);
}
out
}
/// Our controllable avatar. Until `active`, the camera free-flies; once the server reports our
/// position we take control (third-person) and drive movement. Toggle free-fly with `F`.
/// `active`/`pos`/`detached` are `pub(crate)` so terrain streaming can center the loaded block on the
@@ -664,7 +713,10 @@ mod autorun_tests {
#[cfg(test)]
mod move_mode_tests {
use super::{MoveModes, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_HEIGHT, TERMINAL_VELOCITY};
use super::{
incapacitated_flags, MoveModes, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE,
HOVER_HEIGHT, TERMINAL_VELOCITY,
};
use crate::creature_anim::move_flags as f;
use benilla_protocol::MoveMode;
@@ -792,4 +844,104 @@ mod move_mode_tests {
/// Feather fall must be the SLOWER cap — the whole point of the aura. A *compile-time* assert,
/// because both sides are constants: swapped, they would otherwise only show up in play.
const _: () = assert!(FEATHER_TERMINAL_VELOCITY < TERMINAL_VELOCITY);
/// **A root and a stun are different states, and the difference is the pivot** (decision 0872).
/// This is the distinction the first pass got wrong — B179 was filed under the movement-mode
/// family, and it is not a member of it: root is a `MOVEMENTFLAGS` bit that leaves turning live
/// by the input tick's *authored* allow-list, while the freeze is `UNIT_FIELD_FLAGS` bit
/// `0x40000` gating the turn emitter. vmangos's `HandleModStun` grants both at once, which is
/// the only reason they look like one thing.
///
/// The bit values are what the gate reads, so a transcription slip here silently un-freezes
/// every stun; `UNIT_FLAG_STUNNED` is checked against vmangos `UnitDefines.h` and against the
/// reference's own `shr eax, 0x12` (bit 18 → `1 << 18` = `0x40000`).
#[test]
fn a_stun_is_not_a_root() {
assert_eq!(crate::player::UNIT_FLAG_STUNNED, 1 << 18);
assert_eq!(crate::player::UNIT_FLAG_STUNNED, 0x0004_0000);
// It is a UNIT_FIELD_FLAGS bit, not a MOVEMENTFLAGS one — they are different words, and the
// whole confusion was reading the freeze as a movement flag. Nothing in the mode family may
// collide with it.
for mode in [
MoveMode::Root,
MoveMode::WaterWalk,
MoveMode::FeatherFall,
MoveMode::Hover,
] {
assert_ne!(
mode.flag(),
crate::player::UNIT_FLAG_STUNNED,
"{mode:?} is a MOVEMENTFLAGS bit; the stun gate is a descriptor bit"
);
}
// …and a root, alone, is not a stun: granting it touches no unit flag at all.
let mut modes = MoveModes::default();
modes.set(MoveMode::Root, true);
assert_eq!(modes.wire_flags(), crate::creature_anim::move_flags::ROOT);
}
/// The hover clearance is reached by a **rate-limited climb**, not a snap — the reference's
/// second writer. Pins the rate and, more importantly, that the rise takes real time: a body a
/// full yard low needs ~0.143 s, which is the difference between a float and a pop.
#[test]
fn hover_climbs_to_its_clearance_rather_than_popping() {
assert_eq!(HOVER_CLIMB_RATE, 7.0); // [0x87d898], via 0x7c61b0
let dt = 1.0 / 60.0;
let mut clearance = 0.0_f32;
let mut frames = 0;
while clearance < HOVER_HEIGHT {
clearance = (clearance + HOVER_CLIMB_RATE * dt).min(HOVER_HEIGHT);
frames += 1;
}
assert!(
frames > 1,
"a snap would arrive in one frame; this must not"
);
assert!(
(frames as f32 * dt - HOVER_HEIGHT / HOVER_CLIMB_RATE).abs() < 0.02,
"climbed in {frames} frames, expected ≈0.143 s"
);
}
/// **The two incapacitate suppressions take exactly their own bits** (decision 0880) — the
/// difference between a pure root and a stun, which is the whole distinction B179 was drawing,
/// expressed on the one word that drives both the animation and the wire.
#[test]
fn the_incapacitate_suppressions_take_exactly_their_own_bits() {
// Every reported motion a body can hold at once, plus every mode a grant can have riding.
let keys = f::FORWARD | f::STRAFE_LEFT | f::TURN_LEFT;
let modes = f::ROOT | f::SWIMMING | f::SAFE_FALL | f::HOVER | f::WATER_WALKING;
assert_eq!(
incapacitated_flags(keys | modes, false, false),
keys | modes,
"neither gate: the word passes through untouched"
);
assert_eq!(
incapacitated_flags(keys | modes, true, false),
f::TURN_LEFT | modes,
"a PURE root (Frost Nova) takes the direction bits and leaves the pivot — turn is on \
the reference's allow-list on purpose"
);
assert_eq!(
incapacitated_flags(keys, false, true),
f::FORWARD | f::STRAFE_LEFT,
"the two are keyed on different state: the stun bit alone takes only the pivot"
);
// A stun is both at once (vmangos `HandleModStun` = SetFlag(STUNNED) + SetRooted(true)).
let blocked = incapacitated_flags(keys | modes, true, true);
assert_eq!(
blocked, modes,
"Ice Block leaves NO reported motion — which is what starves the anim resolver"
);
// …and the modes must survive it, or the next server-authored echo clears them under us
// (decision 0726's failure mode). ROOT above all: dropping it unroots us locally against a
// server that still has us rooted.
assert_eq!(blocked & f::ROOT, f::ROOT, "the root bit itself rides on");
assert_eq!(
blocked & f::SWIMMING,
f::SWIMMING,
"a rooted swimmer is still swimming — `0xffe07f00` preserves 0x200000"
);
}
}
+74 -1
View File
@@ -261,6 +261,26 @@ fn settle_to_rest(feet_y: f32, surface_y: f32, h: f32) -> f32 {
(feet_y - (surface_y - rest_cap(h))).max(0.0)
}
/// **Does the water own this mover's vertical this frame?** — the rest line [`swim_step`] enforces,
/// or `None` for none at all.
///
/// `None` means exactly one thing: **GM flight** (decision 0726). `LEVITATING` is the reference's
/// suppression of the water's authority over the mover (`0x6030d2`, the same bail
/// [`update_swimming`] takes), and the swim regime with no liquid that owns it has nothing to
/// constrain against — an ascent must be free, or you could dive but never climb.
///
/// A *momentary* sample miss in real water is deliberately **not** that case: it maps to
/// `Some(own feet Y)`, a rest line at our own depth, so the avatar simply holds for the frame.
/// Collapsing the two would make a chunk-seam glitch and GM flight indistinguishable.
///
/// This exists as one named function because the constraint has **two arms** — refuse the rise,
/// satisfy the drop — and B128 was what happened when they read different sources: 0644 added the
/// second arm after 0726 wrote the first, so flight was exempt from the cap and not from the settle
/// (decision 0882).
pub(super) fn rest_line(player: &Player, surface_y: Option<f32>) -> Option<f32> {
(!player.modes.levitating).then(|| surface_y.unwrap_or(player.pos.y))
}
/// Advance the avatar one swim frame: the pitched travel velocity through the client's *floating*
/// physics (VERIFIED, TU-B — gravity bypassed: an idle swimmer's depth is **frozen**, the vertical
/// comes only from `input_vel`), a collide-and-slide against the lakebed/banks, and the hard
@@ -346,7 +366,18 @@ pub(super) fn swim_step(
// Gated on a stroke, matching the resolver's own outer gate (`0x634100 test [esi+0x40],0x200f`
// — translation or falling, never idle): an idle floater is not resolved at all, so its depth
// stays frozen (TU-B(c)).
if input_vel != Vec3::ZERO {
//
// …and gated on **`surface_y.is_some()` — the very same rest line the cap above reads**, which
// is the whole of B128 (decision 0882). The rest line has two arms — refuse the rise, satisfy
// the drop — and they must share one gate, because `None` here means exactly one thing: GM
// flight, the swim regime with no liquid that owns it (0726; a momentary sample miss is
// `Some(own feet Y)` at the caller, never `None`). 0644 added this second arm *after* 0726 and
// read the liquid straight from `surface_at` instead, so the exemption covered the cap and not
// the settle: flying over a lake, `excess` is the entire altitude, the down-cast through open
// air hits nothing, and one frame teleports the avatar onto the waterline. That is both
// reporters' wording exactly — "from any height", and "*when moving* from land to water", the
// `input_vel != ZERO` gate right here.
if surface_y.is_some() && input_vel != Vec3::ZERO {
if let Some(surface_now) = surface_at(c - half_h) {
let excess = settle_to_rest(c.y - half_h.y, surface_now, player.collision_height.0);
if excess > 0.0 {
@@ -560,6 +591,48 @@ mod tests {
}
}
/// **B128, pinned.** `.gm fly` over water yanked the avatar down onto the waterline instantly,
/// from any height (two reporters). Cause: the rest line's two arms read different sources —
/// [`rest_line`] exempted flight from the *cap*, while the *settle* re-read the liquid itself.
/// This pins the exemption at its single source, in both directions, so the two can't diverge
/// again: while levitating there is no line at all, and the moment the mode clears the water
/// has its authority back.
#[test]
fn gm_flight_hands_the_water_no_authority_over_the_vertical() {
let lake_surface = 40.0;
// 300 yd above a lake, `.gm fly` on. This is the exact geometry of B128: liquid is over our
// XY (the query answers `Some`), and the drop the settle would compute is the whole
// altitude — through open air, so nothing stops the cast and one frame lands us in the lake.
let mut flying = player_at(lake_surface + 300.0);
flying.swimming = true;
flying.modes.levitating = true;
assert_eq!(
rest_line(&flying, Some(lake_surface)),
None,
"no rest line while levitating — this is the gate BOTH arms take, not just the cap"
);
// What the settle would have done had it reached its arithmetic: teleport us 300 yd down.
// Asserted so the fix is measured against the defect rather than merely asserted away.
assert!(
settle_to_rest(flying.pos.y, lake_surface, flying.collision_height.0) > 299.0,
"the ungated settle's drop is the whole altitude — that was the yank"
);
// Clear the mode (`.gm fly off` sends flags 0) and the water is back in charge immediately.
flying.modes.levitating = false;
assert_eq!(rest_line(&flying, Some(lake_surface)), Some(lake_surface));
// And the case flight must not be confused with: a *momentary* surface-query miss in real
// water is a rest line at our own feet — the avatar holds for the frame, it does not fly.
let swimmer = player_at(lake_surface - 2.0);
assert_eq!(
rest_line(&swimmer, None),
Some(swimmer.pos.y),
"a chunk-seam miss must stay a Some, or it is indistinguishable from GM flight"
);
}
/// **B76, pinned.** The bug the director reported: a gnome could not swim up to the surface,
/// her head stayed well under. The rest line is `0.75·h` below the waterline, so the head
/// clears it iff `0.75·h < h` — trivially true *for the unit's own h*, and false the moment
+13 -7
View File
@@ -32,8 +32,8 @@
//! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number
//! flushes (decision 0149's flush law, inherited from the shared dispatch).
//!
//! INTERIM readings (flagged for a wow-re pass): players' weapon material is assumed metal and
//! victims' armor lands on the flesh slot (the chain/plate slots need the armor-material chain);
//! INTERIM readings (flagged for a wow-re pass): victims' armor lands on the flesh slot (the
//! chain/plate slots need the armor-material chain);
//! blocks assume a metal shield; the injury vocal plays on every damaging hit (the client may
//! throttle); the deflect (`0x457f20`) and immune/absorb (`0x458610`) positioned stubs' kit ids
//! are unpinned, so those branches stay silent here; the natural-weapon column is gated on
@@ -74,9 +74,9 @@ const COMBAT_MISS_2H: u32 = 7081;
/// Weapon subclasses swung two-handed (item weapon subclass ids) — picks the 2H whoosh.
const TWO_HANDED: [u32; 6] = [1, 5, 6, 8, 10, 17];
/// Weapon subclasses whose body is wood (staff/polearm/fishing pole) — the non-metal row
/// variant, and the wood parry slot when they parry (INTERIM heuristic, module docs).
const WOODEN: [u32; 3] = [6, 10, 20];
/// `Material.dbc` id 2 — a wood-bodied item, which picks the non-metal impact row and the wood
/// parry slot. Read off the item itself (decision 0882), never inferred from its subclass.
const MATERIAL_WOOD: u8 = 2;
/// Fist/unarmed subclass — the row a weaponless swing uses (`Unarmed_Generic`).
const UNARMED_SUBCLASS: u32 = 13;
@@ -104,12 +104,18 @@ fn load_weapon_impacts(mut commands: Commands, assets: Option<Res<WorldAssets>>)
#[derive(Default)]
struct LastSwing(EntityHashMap<SwingMessage>);
/// The attacker's swinging weapon: `(subclass, wooden)`, unarmed when the hand is empty.
/// The attacker's swinging weapon: `(subclass, wooden)`, unarmed when the hand is empty. The
/// wood-vs-metal half is the item's own **`Material`** off the wire (decision 0882) — not a
/// subclass guess, which the real 5875 data contradicts outright: maces (subclass 4) ship in both
/// materials, so a Cudgel is wood where a Mace is metal.
fn swing_weapon(wielded: Option<&Wielded>, offhand: bool) -> (u32, bool) {
let hand = wielded.and_then(|w| if offhand { w.off } else { w.main });
match hand {
// class 2 = weapon; anything else in hand (held misc) swings as unarmed.
Some((2, subclass)) => (u32::from(subclass), WOODEN.contains(&u32::from(subclass))),
Some((2, subclass)) => (
u32::from(subclass),
wielded.is_some_and(|w| w.materials[usize::from(offhand)] == MATERIAL_WOOD),
),
_ => (UNARMED_SUBCLASS, false),
}
}
+7
View File
@@ -52,6 +52,13 @@ impl EmoteSounds {
self.0.emote_flags(emote_id)
}
/// The **stand state** this emote sets, if it is a posture emote (`EmoteSpecProc == 1`) —
/// `DoEmote`'s state branch (wow-re `object-layer/scratch/emote-posture-gate.md` §1). Promoted
/// for `crate::ui_chat`: it is what makes `/sit` actually sit.
pub(crate) fn posture_state(&self, emote_id: u32) -> Option<u32> {
self.0.posture_state(emote_id)
}
/// The `$ESD` anim event's kit for a unit in this looping state emote: the row's
/// `EventSoundID`, gated on `EmoteSpecProc == 2` — the client's `row[+0x10] == 2` test in the
/// `$ESD` handler `0x6239f0` before it reads `row[+0x18]` (wow-re
+45 -43
View File
@@ -1,19 +1,23 @@
//! Draw/stow sounds — the sheath swap moment routed through `SheatheSoundLookups`.
//!
//! The trigger is [`SheathSwapMessage`] — the *ceremony's* hand-touches-weapon moment (the
//! `VisualSheath` release at the one-shot's authored swap point). Ceremony-only: snap
//! The trigger is [`SheathSwapMessage`] — the *ceremony's* hand-touches-weapon moment, fired once
//! **per arm** as that arm's one-shot crosses its authored swap point. Ceremony-only: snap
//! transitions (attack auto-draw, every reactive stow, remote units) are **silent**, like the
//! client's — the sound lives in the ceremony playback, and `bInstant` paths play no clip
//! (director-verified on the ref). NOT the `$SHL`/`$SHR` anim tags: those exist only on Sheath
//! (89, the back-stow ceremony) — **HipSheath (90, every 1H draw) carries no sound events at
//! all** (probe-verified on HumanMale; director-caught silence on a sword-and-board draw), so
//! the event track cannot be the client's trigger. Each wielded hand resolves its item through
//! the event track cannot be the client's trigger. The moving slot resolves its item through
//! `SheatheSoundLookups` (`(class, subclass, material)` → stow/draw kit pair — metal/wood
//! weapons, shields); empty hands are silent.
//! weapons, shields); an empty slot is silent.
//!
//! INTERIM: item material isn't on the wire for players — wood for staves/polearms/fishing
//! poles, metal otherwise (the same heuristic as `combat`); creatures could carry the virtual
//! item's material byte through `Wielded` later.
//! The pick's only real input is the item's **`Material`** (decision 0882): the 5875 table is one
//! row per weapon subclass per material, and every row of a material carries the same kit pair —
//! metal 698/700, wood 697/699 — so the subclass is inert and the material decides everything. It
//! rides the wire both ways (`SMSG_ITEM_QUERY_SINGLE_RESPONSE` for players, the
//! `UNIT_VIRTUAL_ITEM_INFO` byte triple for creatures) and reaches here through `Wielded`, so
//! nothing is guessed. Shields land on their own class-4 rows, whose material is a don't-care the
//! lookup's fallback resolves.
use bevy::prelude::*;
@@ -27,9 +31,6 @@ use crate::schedule::WorldStage;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// Wood-bodied weapon subclasses (staff/polearm/fishing pole) — the material heuristic.
const WOODEN: [u32; 3] = [6, 10, 20];
#[derive(Resource)]
struct SheatheSounds(SheatheSoundCatalog);
@@ -70,39 +71,40 @@ fn sheathe_sounds(
let Ok((transform, wielded)) = units.get(swap.entity) else {
continue;
};
// Each wielded hand rings its own item (a sword-and-board draw = ring + shield thunk).
for hand in [wielded.main, wielded.off] {
let Some((class, subclass)) = hand else {
continue; // empty hand — nothing to ring
};
let (class, subclass) = (u32::from(class), u32::from(subclass));
let material = if class == 2 && WOODEN.contains(&subclass) {
2
} else if class == 2 {
1
} else {
0
};
let Some(pair) = sounds.0.get(class, subclass, material) else {
continue;
};
let kit = if swap.drawing {
pair.unsheathe
} else {
pair.sheathe
};
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("sheathe (kit {kit}): {e:#}");
}
// One message per arm, naming the slot whose model just moved — so a sword-and-board draw
// is a ring plus a shield thunk (two messages, one clip each), and the second movement of
// a melee → ranged toggle rings the bow rather than the swords it just put away.
let item = match swap.slot {
0 => wielded.main,
1 => wielded.off,
_ => wielded.ranged,
};
let Some((class, subclass)) = item else {
continue; // empty slot — nothing to ring
};
let material = u32::from(wielded.materials[usize::from(swap.slot).min(2)]);
let Some(pair) = sounds
.0
.get(u32::from(class), u32::from(subclass), material)
else {
continue;
};
let kit = if swap.drawing {
pair.unsheathe
} else {
pair.sheathe
};
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("sheathe (kit {kit}): {e:#}");
}
}
}
+8
View File
@@ -43,6 +43,7 @@ mod errors;
mod feed;
#[cfg(test)]
mod feed_tests;
mod ranks;
mod state;
pub(crate) mod targeting;
pub(crate) mod toggle;
@@ -250,6 +251,13 @@ impl Plugin for UiActionPlugin {
// routes entirely to `action_sets`, never also queuing a use), so the drains'
// relative order doesn't matter. The dynamic-state feed follows the identity
// feed so a fresh slot's first state push lands the same frame.
// The rank pass runs on the same `dirty` flag the identity feed consumes,
// and strictly before it: a slot corrected here is resolved and pushed with
// its right rank the same frame, so a stale rank never reaches a pixel
// (decision 0883).
ranks::normalize_action_ranks
.in_set(UnitFeed)
.before(feed::feed_actions),
feed::feed_actions.in_set(UnitFeed).before(UiInput),
state::feed_action_state
.in_set(UnitFeed)
+237
View File
@@ -0,0 +1,237 @@
//! **A bar slot never holds a rank the character has outgrown** (decision 0883) — the one place
//! that keeps the action bar's spell ids in step with the spell book's ranks.
//!
//! The bar is client-authoritative (decision 0218 §4): the server stores 120 packed words and
//! hands them back at login, and nothing on the server side ever rewrites them when a rank is
//! learned (VERIFIED vmangos — `Player::AddSpell`'s supersede path touches only the spell store;
//! the only `ConvertSpell` that walks `character_action` is the race-change tool). So the stored
//! bar drifts, and it drifts *silently*: a superseded rank is dropped from the book
//! (`Player::SendInitialSpells` skips `!active` spells), so the button comes back pointing at a
//! spell the character no longer knows — dead, with no way to tell from the icon, which is the
//! same art on every rank. That is the reported bug: a level-60 rogue's saved slot 1 still held
//! Sinister Strike **rank 1** (`character_action.action = 1752`) while the book held only rank 8
//! (11294), so the button did nothing.
//!
//! The chain lives in `SkillLineAbility.dbc`'s `forward_spellid` — 406 of the build's 4753 spells
//! carry it, and they are exactly the abilities the server supersedes (every
//! `SMSG_SUPERCEDED_SPELL` vmangos sends is gated on `GetSpellBookSuccessorSpellId`, which reads
//! that column). Warrior/rogue physical abilities and the profession tier openers are chained;
//! caster nukes and heals are not — their lower ranks stay known and castable, which is what
//! makes vanilla down-ranking work. Normalization therefore cannot touch a deliberate down-rank:
//! it only ever moves within a chain, and a chain is precisely a family where only one rank is
//! ever known at a time.
//!
//! This runs on the `dirty` flag — every book arrival, rank-up and local slot write — so it is
//! ONE mechanism covering all of them, not a login special case. Hence
//! [`super::spells::superceded_spell`] no longer re-points buttons itself: the server's
//! `SMSG_SUPERCEDED_SPELL` moves the *book*, and the bar follows from here. It has to be that way
//! round, because the case that bit us got no supersede packet at all (vmangos suppresses them
//! while the character is loading, so ranks gained offline — a `.levelup`, a boost — arrive as a
//! book that silently disagrees with the bar).
use bevy::prelude::*;
use benilla_protocol::messages::ACTION_KIND_SPELL;
use super::PlayerActions;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_spellbook::SkillLines;
/// Re-point every spell button at the highest rank of its ability the book actually holds, and
/// persist each move (decision 0216 §7: a local slot mutation IS a `CMSG_SET_ACTION_BUTTON` — the
/// bar has no other writer, so a fix we keep to ourselves would be re-applied every single login
/// while the server's copy stayed wrong).
///
/// Gated on `dirty` and run before [`super::feed::feed_actions`] consumes it, so the corrected id
/// is what the feed resolves and pushes — the stale rank never reaches a frame.
pub(super) fn normalize_action_ranks(
mut actions: ResMut<PlayerActions>,
skill_lines: Option<Res<SkillLines>>,
commands: Res<NetCommands>,
) {
let Some(skill_lines) = skill_lines else {
return;
};
if !actions.dirty {
return;
}
// Resolve first (the walk reads `buttons` and `spells` together), then write: an empty book
// yields no fixes at all, which is what keeps a bar-before-book packet order harmless.
let fixes: Vec<(u8, u32)> = actions
.buttons
.values()
.filter(|b| b.kind == ACTION_KIND_SPELL)
.filter_map(|b| {
let top = skill_lines
.catalog
.highest_known_rank(b.action, &actions.spells)?;
(top != b.action).then_some((b.slot, top))
})
.collect();
for (slot, top) in fixes {
let Some(button) = actions.buttons.get_mut(&slot) else {
continue;
};
let was = button.action;
button.action = top;
let packed = top | (u32::from(button.kind) << 24);
info!("ui_action: bar slot {slot} rank-normalized {was} -> {top}");
let _ = commands.0.send(ClientCommand::SetActionButton {
button: slot,
packed,
});
}
}
#[cfg(test)]
mod tests {
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO};
use crossbeam_channel::Receiver;
use super::*;
/// Sinister Strike's real chain, ranks 1..8 (pinned against the DBC by `benilla_formats`'
/// own `real_rank_chains_resolve_the_highest_known_rank`).
const SS: [u32; 8] = [1752, 1757, 1758, 1759, 1760, 8621, 11293, 11294];
/// These run the walk against the **real** `SkillLineAbility.dbc` — the chain is the thing
/// under test, and a hand-built fake chain would only test the walk against itself.
fn client_data() -> Option<std::path::PathBuf> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
Some(data)
}
fn spell_button(slot: u8, action: u32) -> ActionButton {
ActionButton {
slot,
action,
kind: ACTION_KIND_SPELL,
}
}
/// Run the system once over `buttons` + a book of `spells`, on the real catalog. Returns the
/// resulting store and the `(button, packed)` pairs that went out on the wire.
fn normalize(
data: &std::path::Path,
buttons: &[ActionButton],
spells: &[u32],
) -> (PlayerActions, Vec<(u8, u32)>) {
let mut chain = benilla_formats::open_chain(data).expect("open chain");
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
let (tx, rx): (_, Receiver<ClientCommand>) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(PlayerActions {
buttons: buttons.iter().map(|b| (b.slot, *b)).collect(),
spells: spells.iter().copied().collect(),
dirty: true,
})
.insert_resource(SkillLines { catalog })
.insert_resource(NetCommands(tx))
.add_systems(Update, normalize_action_ranks);
app.update();
let sent = rx
.try_iter()
.map(|c| match c {
ClientCommand::SetActionButton { button, packed } => (button, packed),
_ => panic!("the rank pass sends nothing but SetActionButton"),
})
.collect();
let store = app.world_mut().remove_resource::<PlayerActions>().unwrap();
(store, sent)
}
/// **The reported bug.** A level-60 rogue's saved slot 1 holds Sinister Strike rank 1 while
/// the book holds only rank 8: the slot moves to rank 8 AND the move goes out on the wire, so
/// the server's stored copy stops being wrong.
#[test]
fn a_stale_rank_1_slot_moves_to_the_known_rank_and_persists() {
let Some(data) = client_data() else { return };
let (store, sent) = normalize(&data, &[spell_button(0, 1752)], &[11294]);
assert_eq!(store.buttons[&0].action, 11294, "slot 1 holds rank 8");
assert!(store.dirty, "the feed still re-resolves the slot");
assert_eq!(sent, vec![(0, 11294)]);
}
/// Every rank below the known one lands on the same answer — including from *above* it, the
/// down-rank direction a forward-only walk can't reach. The already-correct slot doesn't move
/// and doesn't send.
#[test]
fn every_wrong_rank_converges_and_the_right_one_stays_put() {
let Some(data) = client_data() else { return };
let buttons: Vec<ActionButton> = SS
.iter()
.enumerate()
.map(|(i, &id)| spell_button(i as u8, id))
.collect();
// Rank 4 (1759) known: ranks 1-3 move UP to it, ranks 5-8 move DOWN to it.
let (store, sent) = normalize(&data, &buttons, &[1759]);
for slot in 0..8u8 {
assert_eq!(store.buttons[&slot].action, 1759, "slot {slot}");
}
assert_eq!(sent.len(), 7, "one send per moved slot (slot 3 was right)");
assert!(!sent.iter().any(|(b, _)| *b == 3));
}
/// A caster's down-rank is NOT a mistake: Fireball's ranks carry no `forward_spellid`, so a
/// slot holding rank 1 stays on rank 1 even with rank 2 known and castable.
#[test]
fn an_unchained_caster_rank_is_left_alone() {
let Some(data) = client_data() else { return };
let (store, sent) = normalize(&data, &[spell_button(0, 133)], &[133, 143]);
assert_eq!(store.buttons[&0].action, 133);
assert!(sent.is_empty(), "nothing to persist");
}
/// With no rank of the chain known — the book hasn't arrived yet, or the ability was never
/// learned — the slot is left exactly as it is rather than pointed somewhere arbitrary.
#[test]
fn an_unknown_chain_leaves_the_slot_untouched() {
let Some(data) = client_data() else { return };
let (store, sent) = normalize(&data, &[spell_button(0, 1752)], &[]);
assert_eq!(store.buttons[&0].action, 1752);
assert!(sent.is_empty());
}
/// Macro and item slots carry ids from other namespaces — walking them as spell ranks would
/// be nonsense, so the kind byte gates it.
#[test]
fn macro_and_item_slots_are_never_walked() {
let Some(data) = client_data() else { return };
let buttons = [
ActionButton {
slot: 0,
action: 1752,
kind: ACTION_KIND_MACRO,
},
ActionButton {
slot: 1,
action: 1752,
kind: ACTION_KIND_ITEM,
},
];
let (store, sent) = normalize(&data, &buttons, &[11294]);
assert_eq!(store.buttons[&0].action, 1752);
assert_eq!(store.buttons[&1].action, 1752);
assert!(sent.is_empty());
}
/// A correct bar is a no-op — nothing moves, nothing is sent. That is what lets this ride the
/// `dirty` flag (set by every drag-and-drop) without adding traffic.
#[test]
fn a_correct_bar_sends_nothing() {
let Some(data) = client_data() else { return };
// Rank 8 on the bar, plus the auto-attack (no `SkillLineAbility` row at all).
let buttons = [
spell_button(0, 11294),
spell_button(1, super::super::SPELL_ATTACK),
];
let (_, sent) = normalize(&data, &buttons, &[11294, super::super::SPELL_ATTACK]);
assert!(sent.is_empty());
}
}
+23 -9
View File
@@ -8,8 +8,10 @@
//! equipped item (leg 4) · the combo-point gate (leg 5) · the shapeshift-form gate (leg 6,
//! [`SpellDisplay::usable_in_form`]) · only-stealthed (leg 7) · not-in-combat (leg 8) ·
//! CasterAuraState (leg 9) · TargetAuraState + its CanAttack/CanAssist fork (legs 10/10b — the
//! ONE target-dependent pair; our per-frame diff-push recomputes it on target change for free,
//! where the ref re-runs its cache on events) · the bit-25 cooldown fold (leg 11) · power (leg 12).
//! ONE target-dependent pair, §5-proven: the current-target GUID globals `0xb4e2d8/dc` appear
//! nowhere earlier in the whole function, 0879; our per-frame diff-push recomputes it on target
//! change for free, where the ref re-runs its cache on events) · the bit-25 cooldown fold
//! (leg 11) · power (leg 12).
//!
//! Deferred, named (each answers usable=true until modeled): leg 2's caster aura-immunity
//! helpers (`0x6e9f20/40/60` — silence/pacify vs the spell's school/mechanic; needs an aura-type
@@ -112,13 +114,25 @@ pub(crate) fn spell_usable(
if !equipped_item_fits(d, ctx.store, items, commands) {
return (false, false);
}
// Leg 5 (`0x6e3e95`–`0x6e3eb2`): the combo-point gate. A finishing move (`AttributesEx`
// b20/b22) is unusable while the caster's combo-point byte is 0 — `[caster+0xe68]+0x1029`,
// i.e. `PLAYER_FIELD_BYTES` byte 1 off the player-block base, one byte below the honor rank
// the item-usable gate reads at `+0x102b`. CASTER-only: the client never checks that the
// points sit on the CURRENT target (the server does, and refuses a mismatch with
// `SPELL_FAILED_BAD_TARGETS`) — see decision 0869. This is Overpower's whole window: it has
// no aura state, so every other leg passes and the button stayed lit forever without it.
// Leg 5 (`0x6e3e7a`–`0x6e3eb2`): the combo-point gate, §5-VERIFIED end to end (0879). A
// `test [SpellRec+0x1c], 0x500000` selects finishing moves — ONE any-of test with one jcc, so
// `AttributesEx` b20 and b22 never fork — and then `mov al,[ecx+0x1029]; test al,al` fails the
// leg when the caster's combo-point byte is 0. That is `PLAYER_FIELD_BYTES` byte 1 off the
// player-block base, one byte below the honor rank the item-usable gate reads at `+0x102b`.
// The leg's second clause (`0x6e3e83`–`0x6e3e9e`) re-checks the caster's own GUID against the
// active player's — structurally tautological, since `esi` was found by that same GUID.
//
// CASTER-only, now proven rather than assumed: the current-target GUID globals `0xb4e2d8/dc`
// appear nowhere before leg 10 in the whole of `0x6e3d60`. So a point banked on mob A with mob
// B selected really does leave this button lit, and the server really does refuse it with
// `SPELL_FAILED_BAD_TARGETS` — the client's own divergence, not ours (0869, 0879). Overpower
// is this leg's whole reason to exist: it has no aura state, so every other leg passes and the
// button stayed lit forever without it.
//
// NO class gate here, and that asymmetry is the whole point: the Lua `GetComboPoints 0x51a190`
// reads this same byte behind `cmp al,4 / cmp al,0xb`, so a warrior's Overpower point greys
// the button through this leg while lighting no combo dot (decision 0875). Anything the
// *client* gates on reads the wire, never `GetComboPoints`.
if d.needs_combo_points() && ctx.store.0.player_combo_points().unwrap_or(0) == 0 {
return (false, false);
}
+9 -2
View File
@@ -3,13 +3,16 @@
//! every source (wire lines, channel notices, rolls, client-composed loot/system lines) into
//! events with names resolved ask-once; [`frames`] is the router + the `ChatFrame_OnEvent`
//! composer fanning lines across the docked windows; [`input`] is the outbound side (the chat
//! EditBox, the slash grammar, the send-side emote posture gate). This face wires them into
//! [`UiChatPlugin`] and re-exports the crate-facing API (`ChatLog`, `ChatEvent`, `ChatEventKind`).
//! EditBox, the slash grammar, the send-side emote posture gate) over [`commands`]'s table of every
//! `/command` the client answers (decision 0881, built from the reference's own alias strings).
//! This face wires them into [`UiChatPlugin`] and re-exports the crate-facing API (`ChatLog`,
//! `ChatEvent`, `ChatEventKind`).
use bevy::prelude::*;
use crate::ui_script::UiInput;
mod commands;
mod edit;
mod event;
mod feed;
@@ -29,6 +32,10 @@ impl Plugin for UiChatPlugin {
.init_resource::<frames::ChatWindows>()
.init_resource::<edit::ChatEditState>()
.init_resource::<edit::ChannelState>()
// The slash-command table (decision 0881), built from the reference's own alias strings
// once the VM's globals and the emote catalog exist — both are `Startup`, so this runs
// at the next schedule rather than chasing an ordering constraint into two modules.
.add_systems(PostStartup, commands::build_slash_commands)
// Push before the input pass so a line is on screen the same frame it decodes (mirrors
// the loot/merchant feeds).
.add_systems(Update, feed::feed_chat.before(UiInput))
+306
View File
@@ -0,0 +1,306 @@
//! The slash-command **table** (decision 0881): every `/command` the client answers, built at boot
//! from the reference's OWN alias strings rather than hand-typed here.
//!
//! ## Why a table
//!
//! `ChatEdit_ParseText` (ChatFrame.lua l.2163-2199) resolves a typed command in three passes, and
//! **none of them holds a literal**: the chat-type switches walk `SLASH_<ChatTypeInfo index><n>`,
//! the actions walk `SLASH_<SlashCmdList index><n>`, and the emotes walk `EMOTE<i>_CMD<j>` →
//! `EMOTE<i>_TOKEN` → `DoEmote(token)`. The aliases are DATA (`GlobalStrings.lua`), the handlers
//! are code, and the two are joined by an index name.
//!
//! Benilla used to re-type each command's aliases into a Rust `match` as its system was built.
//! Every alias nobody happened to copy was a command that silently did not exist: the audit behind
//! 0881 found **61 live reference aliases dead** (`/lol`, `/hi`, `/ty`, `/congrats`, `/sorry`,
//! `/yes`, `/bravo`, …) because the emote arm matched the *`EmotesText.dbc` name* instead of the
//! alias table — and four names the reference has NO command for (`/joke`, `/puzzle`,
//! `/attackmytarget`, and `/follow`, which fired a text emote where the real client follows your
//! target) answering as emotes. A table keyed on the shipped strings cannot have that class of bug:
//! registering a handler is naming its index, and the aliases come from the data.
//!
//! ## Where each half comes from
//!
//! - **`SLASH_<INDEX><n>`** — the real `GlobalStrings.lua`, already executed into the UI VM at boot
//! (`crate::ui_script`). One [`SlashIndex`] per command benilla implements; the reference's own
//! index name is the key.
//! - **`EMOTE<i>_CMD<j>` / `EMOTE<i>_TOKEN`** — the alias→token join, also read off the VM globals
//! (`crate::ui_script::load_emote_tokens` runs the token half's data lines out of the shipped
//! `ChatFrame.lua`). The token resolves to an `EmotesText` id through the emote catalog.
//! - **benilla's own instruments** ([`DevCmd`]) — `/castvis`, `/chattest`, `/shot`, … have no
//! reference strings because they are not reference commands; their aliases are literals here,
//! the one place in this module where that is honest.
//!
//! Precedence is the reference's resolution ORDER, made static at insert time: a slash index wins
//! over an emote alias of the same name (the reference reaches `SlashCmdList` first), and benilla's
//! instruments are inserted last so they can never shadow a real command.
use std::collections::HashMap;
use bevy::prelude::*;
/// A command benilla implements, identified by the reference's own `SlashCmdList` index — the
/// `SLASH_<INDEX><n>` alias-string prefix ([`SlashIndex::key`]). Adding a command is adding a
/// variant + its handler arm; its aliases arrive with the shipped strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SlashIndex {
Reply,
Join,
Leave,
ListChannel,
ChatAfk,
ChatDnd,
Random,
Played,
Help,
Logout,
Quit,
Invite,
Uninvite,
Promote,
Duel,
DuelCancel,
Pvp,
Who,
Friends,
RemoveFriend,
Ignore,
Unignore,
Trade,
Inspect,
Script,
LootFfa,
LootRoundRobin,
LootMaster,
}
impl SlashIndex {
/// The reference's index name — the `SLASH_<KEY><n>` prefix its aliases live under.
fn key(self) -> &'static str {
match self {
Self::Reply => "REPLY",
Self::Join => "JOIN",
Self::Leave => "LEAVE",
Self::ListChannel => "LIST_CHANNEL",
Self::ChatAfk => "CHAT_AFK",
Self::ChatDnd => "CHAT_DND",
Self::Random => "RANDOM",
Self::Played => "PLAYED",
Self::Help => "HELP",
Self::Logout => "LOGOUT",
Self::Quit => "QUIT",
Self::Invite => "INVITE",
Self::Uninvite => "UNINVITE",
Self::Promote => "PROMOTE",
Self::Duel => "DUEL",
Self::DuelCancel => "DUEL_CANCEL",
Self::Pvp => "PVP",
Self::Who => "WHO",
Self::Friends => "FRIENDS",
Self::RemoveFriend => "REMOVEFRIEND",
Self::Ignore => "IGNORE",
Self::Unignore => "UNIGNORE",
Self::Trade => "TRADE",
Self::Inspect => "INSPECT",
Self::Script => "SCRIPT",
Self::LootFfa => "LOOT_FFA",
Self::LootRoundRobin => "LOOT_ROUNDROBIN",
Self::LootMaster => "LOOT_MASTER",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 28] = [
Self::Reply,
Self::Join,
Self::Leave,
Self::ListChannel,
Self::ChatAfk,
Self::ChatDnd,
Self::Random,
Self::Played,
Self::Help,
Self::Logout,
Self::Quit,
Self::Invite,
Self::Uninvite,
Self::Promote,
Self::Duel,
Self::DuelCancel,
Self::Pvp,
Self::Who,
Self::Friends,
Self::RemoveFriend,
Self::Ignore,
Self::Unignore,
Self::Trade,
Self::Inspect,
Self::Script,
Self::LootFfa,
Self::LootRoundRobin,
Self::LootMaster,
];
}
/// Benilla's own diagnostic commands — instruments we built, with no reference strings behind them
/// (decisions 0099 `/castvis`, 0288 `/chattest`, 0434 `/partytest`, 0600 `/shot`, 0634 `/liquid`,
/// 0637 `/reaction`). Their aliases are literals because there is nothing shipped to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DevCmd {
CastVis,
ChatTest,
PartyTest,
Shot,
Liquid,
Reaction,
}
impl DevCmd {
fn aliases(self) -> &'static [&'static str] {
match self {
Self::CastVis => &["castvis"],
Self::ChatTest => &["chattest"],
Self::PartyTest => &["partytest"],
Self::Shot => &["shot"],
Self::Liquid => &["liquid"],
Self::Reaction => &["reaction", "react"],
}
}
const ALL: [Self; 6] = [
Self::CastVis,
Self::ChatTest,
Self::PartyTest,
Self::Shot,
Self::Liquid,
Self::Reaction,
];
}
/// What a typed `/command` resolved to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Command {
/// A reference `SlashCmdList` command benilla implements.
Slash(SlashIndex),
/// An emote alias — carrying the `EmotesText` id its token resolved to (`/lol` → LAUGH → 45).
Emote { text_id: u32 },
/// One of benilla's own instruments.
Dev(DevCmd),
}
/// The built table: alias (lowercase, no leading `/`) → command.
#[derive(Resource, Default)]
pub(crate) struct SlashCommands {
by_alias: HashMap<String, Command>,
/// How many aliases each source contributed — the boot line that makes a broken table (an
/// unreadable `GlobalStrings.lua`, a missing token table) visible immediately instead of at
/// the first command someone types.
counts: (usize, usize),
}
impl SlashCommands {
/// Resolve a typed command (no leading `/`, any case).
pub(super) fn lookup(&self, cmd: &str) -> Option<Command> {
self.by_alias.get(&cmd.to_ascii_lowercase()).copied()
}
/// Build from the reference's alias strings. `get` reads a global string (the UI VM's globals in
/// production, a stub map in tests); `text_id` resolves an `EmotesText` NAME to its id (the
/// emote catalog). Pure over those two, so the whole table is testable without a VM.
pub(super) fn build(
get: impl Fn(&str) -> Option<String>,
text_id: impl Fn(&str) -> Option<u32>,
) -> Self {
let mut by_alias: HashMap<String, Command> = HashMap::new();
// 1 · the action commands: SLASH_<INDEX><n>, walked from 1 until the first gap (the
// reference's own `while cmdString` walk).
for index in SlashIndex::ALL {
for n in 1.. {
let Some(alias) = get(&format!("SLASH_{}{n}", index.key())) else {
break;
};
insert(&mut by_alias, &alias, Command::Slash(index));
}
}
let slash_aliases = by_alias.len();
// 2 · the emotes: EMOTE<i>_CMD<j> → EMOTE<i>_TOKEN → EmotesText id. The outer walk stops at
// the first index with no CMD1, exactly like `ChatEdit_ParseText`'s.
for i in 1.. {
let Some(first) = get(&format!("EMOTE{i}_CMD1")) else {
break;
};
// A token with no `EmotesText` row (EMOTE27 is "UNUSED") has nothing to send: the
// reference's `DoEmote` would find no record either.
let resolved = get(&format!("EMOTE{i}_TOKEN")).and_then(|t| text_id(&t));
if let Some(text_id) = resolved {
insert(&mut by_alias, &first, Command::Emote { text_id });
for j in 2.. {
let Some(alias) = get(&format!("EMOTE{i}_CMD{j}")) else {
break;
};
insert(&mut by_alias, &alias, Command::Emote { text_id });
}
}
}
let emote_aliases = by_alias.len() - slash_aliases;
// 3 · benilla's instruments, last so they can never shadow a shipped command.
for dev in DevCmd::ALL {
for alias in dev.aliases() {
insert(&mut by_alias, alias, Command::Dev(dev));
}
}
Self {
by_alias,
counts: (slash_aliases, emote_aliases),
}
}
/// `(slash aliases, emote aliases)` — how many DISTINCT commands each source made reachable
/// (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are both `"/sit"`). The boot report.
pub(super) fn counts(&self) -> (usize, usize) {
self.counts
}
}
/// Insert-if-vacant: the FIRST source to claim an alias keeps it, which is how the reference's pass
/// order (`SlashCmdList` before emotes) becomes a static table.
fn insert(map: &mut HashMap<String, Command>, alias: &str, cmd: Command) {
let key = alias.trim().trim_start_matches('/').to_ascii_lowercase();
if !key.is_empty() {
map.entry(key).or_insert(cmd);
}
}
/// Build the table once the VM (its globals) and the emote catalog both exist. `PostStartup` rather
/// than an ordering constraint: both producers are `Startup` systems whose `insert_resource` lands
/// at the schedule boundary, so this needs no fragile `.after()` chain into two other modules.
pub(crate) fn build_slash_commands(
mut commands: Commands,
script: Option<NonSend<benilla_ui::script::UiScript>>,
emotes: Option<Res<crate::sound::EmoteSounds>>,
) {
let (Some(script), Some(emotes)) = (script, emotes) else {
warn!("chat: no UI VM or emote catalog — the slash-command table is EMPTY");
commands.insert_resource(SlashCommands::default());
return;
};
let globals = script.lua().globals();
let table = SlashCommands::build(
|name| globals.get::<String>(name).ok().filter(|s| !s.is_empty()),
|token| emotes.text_id(token),
);
let (slash, emote) = table.counts();
// The "it loaded" signal: the shipped 1.12 data yields 55 distinct aliases across the
// registered indices and 225 emote commands (pinned by `real_alias_table_resolves_the_shipped_
// commands`).
info!("chat: slash table — {slash} command aliases, {emote} emote aliases");
if emote == 0 {
error!("chat: NO emote aliases — every /wave-style command is dead (GlobalStrings?)");
}
commands.insert_resource(table);
}
@@ -1,15 +1,22 @@
//! The **submitted-line** side of the chat (decision 0288 P5): [`drain_chat_input`] routes each
//! Entered line — a plain line sends as the box's CURRENT type (the edit machine's law,
//! [`super::edit`]), a `/`-line runs the Enter-path type switch, the reply arm, the action
//! grammar ([`parse_line`] → [`ParsedChat`]: `/join`-family, `/afk`, `/random`, `/played`,
//! `/help`, `/wave`-style emotes with the send-side posture gate [`emote_send_eligible`], and
//! the dev commands). Opening keys + live parsing live in [`super::edit`]; the inbound half is
//! [`super::feed`]. Own sends are never echoed locally — the wire echoes back (vanilla behavior).
//! The **submitted-line** side of the chat (decision 0288 P5) — what we DO about a line.
//! [`drain_chat_input`] routes each Entered line: a plain line sends as the box's CURRENT type (the
//! edit machine's law, [`super::edit`]); a `/`-line runs the Enter-path type switch, the reply arm,
//! then the action table. What a line *means* is [`parse`]'s ([`parse_line`] → [`ParsedChat`]);
//! which strings reach which arm is [`super::commands`]'s table.
//!
//! The emote arm is the client's `DoEmote` (`0x5ef560`) in full: the eligibility gate
//! ([`emote_send_eligible`]), the asleep gate, the stow, the **posture** branch that makes `/sit`
//! sit, then `CMSG_TEXT_EMOTE`. Opening keys + live parsing live in [`super::edit`]; the inbound
//! half is [`super::feed`]. Own sends are never echoed locally — the wire echoes back (vanilla
//! behavior).
use bevy::prelude::*;
mod parse;
pub(super) use parse::{parse_enter_type_switch, parse_line, ParsedChat};
use crate::creature_anim::{move_flags, MovementState};
use crate::net::{ChatKind, ClientCommand, NetCommands, SelfPlayer};
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use crate::target::Selection;
/// Send `text` as the box's CURRENT type (`ChatEdit_SendText`): whisper/channel targets ride
@@ -115,6 +122,17 @@ pub(super) struct ChatProbes<'w, 's> {
guids: Res<'w, crate::net::GuidIndex>,
}
/// The two setters `DoEmote` drives besides the packet, bundled (the drain is at Bevy's 16-param
/// ceiling): the **posture** (`EmoteSpecProc == 1` → `SetStandState`) and the **stow**
/// (`SetSheatheState(0, SNAP)`, unconditional on every emote that passes the gates — wow-re
/// `sheath-policy.md` §1, site `0x5ef630`). Both are queues into their subsystem's one setter,
/// never applied here.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct EmoteOut<'w> {
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
#[allow(clippy::too_many_arguments)]
pub(super) fn drain_chat_input(
@@ -136,6 +154,9 @@ pub(super) fn drain_chat_input(
mut cast_events: MessageWriter<crate::creature_anim::CastEvent>,
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
// The command table (decision 0881) — the reference's own aliases, resolved at boot.
table: Res<super::commands::SlashCommands>,
mut emote_out: EmoteOut,
probes: ChatProbes,
) {
let ChatProbes {
@@ -189,8 +210,7 @@ pub(super) fn drain_chat_input(
send_current(&mut state, &commands, remainder);
continue;
}
let resolve_emote = |name: &str| emotes.as_deref().and_then(|e| e.text_id(name));
match parse_line(msg, resolve_emote) {
match parse_line(&table, msg) {
ParsedChat::Reply { text } => match state.last_tell.front().cloned() {
Some(target) => {
state.chat_type = super::edit::SendType::Whisper;
@@ -483,7 +503,7 @@ pub(super) fn drain_chat_input(
// string literal, so it is escaped: a `/who` filter legitimately contains quotes
// (`z-"Elwynn Forest"`), and a newline would end the statement.
ParsedChat::Social { verb, arg } => {
let lua = format!("{}(\"{}\")", verb.lua_fn(), escape_lua_string(&arg));
let lua = format!("{}(\"{}\")", verb.lua_fn(), parse::escape_lua_string(&arg));
if let Err(e) = script.run(&lua) {
warn!("ui_chat(social): {e}");
}
@@ -495,8 +515,12 @@ pub(super) fn drain_chat_input(
"Chat: /s /y /p /g /o /raid /rw /bg, /w <name>, /r, /e",
"Channels: /join <name> [pw], /leave <name>, /chatlist <name>",
"Party: /invite /uninvite /promote [name — bare uses your target]",
"Loot: /ffa /roundrobin /master <name>",
"Duel: /duel [name — bare uses your target], /forfeit (/concede /yield)",
"Misc: /afk, /dnd, /random [min] [max], /played, /shot, /liquid, /logout",
"Social: /who [filter], /friends, /ignore, /trade, /inspect",
"Emotes: /sit /stand /sleep /kneel and every /wave-style emote",
"Misc: /afk, /dnd, /random [min] [max], /played, /logout, /quit",
"Instruments: /shot, /liquid, /reaction, /castvis, /chattest, /partytest",
] {
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
@@ -504,35 +528,66 @@ pub(super) fn drain_chat_input(
));
}
}
// `DoEmote` (`0x5ef560`) end to end — wow-re `object-layer/scratch/emote-posture-
// gate.md` §1. The gates in the client's own order, then the two things it DOES: set a
// posture (the `/sit` family) and send the packet.
ParsedChat::TextEmote(text_id) => {
// The posture-eligibility gate (`emote_send_eligible`): a chat-only text emote (no
// Emotes.dbc row) has no EmoteFlags to test and always sends.
let (stand_state, flags) = self_player
.single()
.map_or((0, 0), |(_, m, _)| (m.stand_state, m.flags));
let swimming = flags & move_flags::SWIMMING != 0;
let emote_id = emotes.as_deref().and_then(|e| e.text_emote(text_id));
// A chat-only text emote (no Emotes.dbc row) has no EmoteFlags to test and no
// posture to set — it always sends.
let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id));
// GATE A (`CheckEmoteEligible` `0x47db40`): suppresses the anim AND the packet.
let eligible = match emotes.as_deref() {
Some(e) => e
.text_emote(text_id)
.and_then(|emote_id| e.emote_flags(emote_id))
Some(e) => emote_id
.and_then(|id| e.emote_flags(id))
.is_none_or(|f| emote_send_eligible(f, stand_state, swimming)),
None => true,
};
if eligible {
let target = selection.guid.unwrap_or(0);
match commands
.0
.send(ClientCommand::TextEmote { text_id, target })
{
Ok(()) => info!("chat: sent {msg:?}"),
Err(_) => warn!("chat: not connected; dropped {msg:?}"),
}
} else {
// GATE B (`0x5ef5f3`): asleep, only a POSTURE emote gets through — which is how
// `/stand` (and `/sit`) is the way out of `/sleep`, while a `/wave` in bed does
// nothing at all.
let awake_or_posture = stand_state != 3 || posture.is_some();
if !eligible || !awake_or_posture {
// Byte-verified whole-send suppression: a seated /bow sends nothing (no packet,
// no anim) — the client's DoEmote returns before building the packet.
debug!(
"chat: emote {text_id} suppressed by posture gate (seated/asleep/swimming)"
"chat: emote {text_id} suppressed (eligible {eligible}, \
awake-or-posture {awake_or_posture}, stand {stand_state})"
);
continue;
}
// The stow (`0x5ef630`, VERIFIED unconditional at this point in the flow): every
// emote that reaches here puts the weapons away, instantly — a `/wave` mid-fight
// sheathes in the reference too. The anim layer's one setter owns the idempotency
// refusal, so an already-stowed body costs nothing.
if let Ok((entity, _, _)) = self_player.single() {
emote_out.sheath.write(crate::creature_anim::SheathRequest {
entity,
state: 0,
ceremony: false,
});
}
// The posture branch (`EmoteSpecProc == 1` → `SetStandState(param)`): the emote's
// whole visible effect, since the SERVER deliberately does nothing for a STATE text
// emote (vmangos `HandleTextEmoteOpcode` breaks out for SIT/SLEEP/KNEEL). Through
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
// local commit until the echo lands, and runs the sit-stow rider.
if let Some(state) = posture {
emote_out
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
let target = selection.guid.unwrap_or(0);
match commands
.0
.send(ClientCommand::TextEmote { text_id, target })
{
Ok(()) => info!("chat: sent {msg:?}"),
Err(_) => warn!("chat: not connected; dropped {msg:?}"),
}
}
ParsedChat::CastVis { spell_id, kind } => {
@@ -584,6 +639,24 @@ pub(super) fn drain_chat_input(
ParsedChat::Logout => {
script.queue_session_request(benilla_ui::script::SessionRequest::Logout)
}
// `/quit` `/exit` — the ref's `Quit()`, same queue as the game menu's Exit button, so
// the field countdown and the confirmation are the ones already built (decision 0674).
ParsedChat::Quit => {
script.queue_session_request(benilla_ui::script::SessionRequest::Quit)
}
// The reference's own handler body, run in the VM (the 0668 posture) — `/trade`,
// `/inspect`, the loot-method trio, and `/script`'s raw chunk.
ParsedChat::Lua { body } => {
if let Err(e) = script.run(&body) {
// `/script` hands the player's own typo back to them; a built-in body failing
// is ours to see in the log.
warn!("ui_chat: {body:?}: {e}");
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
format!("{e}"),
));
}
}
ParsedChat::Unknown => {
// HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203).
chat_log.push_event(super::event::ChatEvent::text_only(
@@ -595,349 +668,6 @@ pub(super) fn drain_chat_input(
}
}
/// Escape a player-typed string for embedding in a Lua double-quoted literal: backslashes and
/// quotes are escaped, and the two line terminators are dropped rather than escaped (nothing a
/// slash command means can contain them, and a stray one would end the statement).
fn escape_lua_string(s: &str) -> String {
s.chars()
.filter(|c| *c != '\n' && *c != '\r')
.flat_map(|c| {
let escaped = matches!(c, '\\' | '"');
escaped
.then_some('\\')
.into_iter()
.chain(std::iter::once(c))
})
.collect()
}
/// Which social slash command was typed — the selector for the Lua body it runs
/// ([`ParsedChat::Social`], decision 0668).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SocialVerb {
/// `/who [filter]` — SLASH_WHO 1-2.
Who,
/// `/friends [name]` (aliases `/friend`) — SLASH_FRIENDS 1-4.
Friends,
/// `/removefriend <name>` (alias `/remfriend`) — SLASH_REMOVEFRIEND 1-4.
RemoveFriend,
/// `/ignore [name]` — SLASH_IGNORE 1-2. Toggles: an ignored name is un-ignored.
Ignore,
/// `/unignore [name]` — SLASH_UNIGNORE 1-2.
Unignore,
}
impl SocialVerb {
/// The Lua function in `FriendsFrame.xml` holding this verb's reference body.
fn lua_fn(self) -> &'static str {
match self {
Self::Who => "BenillaSlashWho",
Self::Friends => "BenillaSlashFriends",
Self::RemoveFriend => "BenillaSlashRemoveFriend",
Self::Ignore => "BenillaSlashIgnore",
Self::Unignore => "BenillaSlashUnignore",
}
}
}
/// What a submitted, already-trimmed chat line resolves to (see [`parse_line`]). Kept separate from
/// [`ClientCommand`] so the parser is unit-testable without a live [`crate::sound::EmoteSounds`]
/// resource or a network channel.
#[derive(Debug, Clone, PartialEq)]
pub(super) enum ParsedChat {
/// `/r [text]` — reply to the last received tell ([`super::edit::ChatEditState::last_tell`]).
Reply { text: String },
/// `/join <name> [password]` (aliases /channel /chan — SLASH_JOIN).
Join { name: String, password: String },
/// `/leave <name>` (aliases /chatleave /chatexit).
Leave { name: String },
/// `/chatlist <name>` (aliases /chatwho /chatinfo) — the member roster ask.
ChatList { name: String },
/// `/afk [msg]` / `/dnd [msg]` — the away toggles (CHAT_MSG_AFK/DND sends).
AfkDnd { kind: ChatKind, msg: String },
/// `/random [min] [max]` (aliases /rand /rnd /roll): bare = 1-100, one number = 1-N.
Random { min: u32, max: u32 },
/// `/played` — CMSG_PLAYED_TIME.
Played,
/// `/shot` — the director's framing instrument (decision 0600): dump the CURRENT world-camera
/// pose as a ready-to-paste capture `Scenario` (raw WoW eye/look + the rendered game minute)
/// into chat and `config_base()`/shots.txt. Client-local, no wire traffic — how a chosen spot
/// travels from the director's eye to the golden set as exact numbers.
Shot,
/// `/liquid` — the swim diagnostic (decision 0634 follow-up): dump the interior claim, the
/// resolved liquid verdict, and every candidate footprint over the player's feet. Client-local,
/// no wire traffic. Built when the canal-tunnel "swim in air" survived the first fix — the
/// question "which surface is claiming this spot, and from which file" had no instrument.
Liquid,
/// `/reaction [name]` — the attackability diagnostic: every input the reaction ladder judges
/// the subject on, the rung that decided, and the resulting `can_attack` verdict. Bare uses
/// the current target; a name resolves a streamed player (what a scripted probe can reach).
Reaction { name: Option<String> },
/// `/help` (aliases /h /?) — the command summary.
Help,
/// A `/name` line that resolved in `EmotesText` (`/wave` → text id 101) — sent as
/// `CMSG_TEXT_EMOTE` targeted at the current selection.
TextEmote(u32),
/// The `/castvis` **dev instrument** (decision 0099 phase 2): synthesize a cast edge locally
/// — the exact [`crate::creature_anim::CastEvent`] the wire would produce — on the selection
/// (else self), no server round-trip. Runtime-available like every current instrument
/// (`ui_pass::demo_enabled`'s note: decision 0026's compile-time `dev` feature is the recorded
/// target seam, not yet built); it moves behind that feature when 0026 phase 1 lands.
CastVis {
spell_id: u32,
kind: crate::creature_anim::CastEventKind,
},
/// `/logout` — leave the world back to character select (`CMSG_LOGOUT_REQUEST`, decision 0193);
/// the vanilla client's own command. The confirmed round-trip (`SMSG_LOGOUT_COMPLETE`) flips
/// [`crate::char_select::ClientState`] back to the roster screen.
Logout,
/// `/chattest` — the 0288 instrument: pump one synthetic line of every kind/notice/link form
/// through the REAL router+composer, so the whole surface is eyeballable in one screen with
/// no server choreography. Dev-runtime like `/castvis` (decision 0026's compile-time gate is
/// the recorded target seam).
ChatTest,
/// `/invite` `/inv` `/i` — party invite by name (`CMSG_GROUP_INVITE`). `None` = bare, which
/// falls back to the selected PLAYER's name (the ref's `GetSlashCmdTarget` law,
/// ChatFrame.lua:650-658 — no name and no player target is a silent no-op).
Invite { name: Option<String> },
/// `/uninvite` `/un` `/u` `/kick` — kick by name (`CMSG_GROUP_UNINVITE`), same
/// name-or-target law.
Uninvite { name: Option<String> },
/// `/promote` `/pr` — hand leadership over (`CMSG_GROUP_SET_LEADER`), same name-or-target
/// law; the 1.12 wire takes a guid, resolved against the roster at dispatch.
Promote { name: Option<String> },
/// `/duel [name]` — challenge a player (the duel spell cast at them). `None` = bare, which
/// falls back to the selected PLAYER exactly like the party commands do — the ref's
/// `SlashCmdList["DUEL"]` is `if GetSlashCmdTarget(msg) then StartDuel(...)`, so no name and
/// no player target is a silent no-op.
Duel { name: Option<String> },
/// `/forfeit` `/concede` `/yield` — `CancelDuel()`. Takes no argument and needs no state
/// check: one opcode covers decline/cancel/forfeit and the server reads the intent.
Forfeit,
/// `/pvp` — `TogglePVP()` (decision 0646 §3). Takes no argument: the binding has no state
/// form, and the server reads the toggle from our current preference.
Pvp,
/// The social verbs (decision 0668) — `/who`, `/friends`, `/removefriend`, `/ignore`,
/// `/unignore`. Each carries the raw argument (`/who`'s is a whole filter string, not a
/// name), and each runs the reference's OWN `SlashCmdList` body, transcribed into
/// `FriendsFrame.xml` as `BenillaSlash*`: those bodies do more than send (a bare `/who`
/// opens the panel and fills its edit box, a bare `/ignore` opens the ignore list), and
/// keeping them in the FrameXML is what stops that behaviour being re-derived here.
Social { verb: SocialVerb, arg: String },
/// `/partytest [lead|invite|mark|off]` — the party-frame dev instrument (decision 0434, the
/// `/chattest` pattern): a synthetic roster through the real apply path (`lead` = the same
/// roster with US leading, for the leader-only popup rows), a fake pending invite for the
/// popup, a local skull on the current target (the mark renders without a server echo), or
/// clear. While the roster is synthetic the drain runs in SANDBOX mode
/// ([`crate::ui_party::GroupState::test`]): the popup's group-mutating rows — promote,
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless.
PartyTest { arg: String },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's
/// player-target fallback.
fn slash_target_name(args: &str) -> Option<String> {
let name = args.split_whitespace().next().unwrap_or("");
(!name.is_empty()).then(|| name.to_string())
}
/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev
/// commands…). The chat-TYPE commands (`/say`-family, `/w`, `/r`) never reach here on the send
/// path — [`parse_enter_type_switch`] and the reply arm consume them first; a plain no-slash
/// line sends as the box's current type ([`drain_chat_input`]). A non-slash line reaching this
/// fn (unit tests only) is [`ParsedChat::Unknown`].
pub(super) fn parse_line(line: &str, resolve_emote: impl Fn(&str) -> Option<u32>) -> ParsedChat {
let Some(rest) = line.strip_prefix('/') else {
return ParsedChat::Unknown;
};
let rest = rest.trim();
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let args = args.trim();
// The dev instrument's grammar: `/castvis <spell_id> [go|fail]` — bare = Start (the precast
// hold begins), `go` = the release, `fail` = the reap. Malformed → Unknown (dropped loudly).
if cmd.eq_ignore_ascii_case("castvis") {
use crate::creature_anim::CastEventKind;
let mut words = args.split_whitespace();
let Some(spell_id) = words.next().and_then(|w| w.parse::<u32>().ok()) else {
return ParsedChat::Unknown;
};
let kind = match words.next() {
None => CastEventKind::Start,
Some(w) if w.eq_ignore_ascii_case("go") => CastEventKind::Go,
Some(w) if w.eq_ignore_ascii_case("fail") => CastEventKind::Fail,
Some(_) => return ParsedChat::Unknown,
};
return ParsedChat::CastVis { spell_id, kind };
}
// `/logout` (also its vanilla alias `/camp`): args are ignored, like the real client.
if cmd.eq_ignore_ascii_case("logout") || cmd.eq_ignore_ascii_case("camp") {
return ParsedChat::Logout;
}
if cmd.eq_ignore_ascii_case("chattest") {
return ParsedChat::ChatTest;
}
if cmd.eq_ignore_ascii_case("partytest") {
return ParsedChat::PartyTest {
arg: args.to_ascii_lowercase(),
};
}
match cmd.to_ascii_lowercase().as_str() {
"r" | "reply" => ParsedChat::Reply {
text: args.to_string(),
},
"join" | "channel" | "chan" => {
let (name, password) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
if name.is_empty() {
// CHAT_JOIN_HELP (the ref's bare-/join reply) rides the Unknown help line.
ParsedChat::Unknown
} else {
ParsedChat::Join {
name: name.to_string(),
password: password.trim().to_string(),
}
}
}
"leave" | "chatleave" | "chatexit" => {
let name = args.split_whitespace().next().unwrap_or("");
if name.is_empty() {
ParsedChat::Unknown
} else {
ParsedChat::Leave {
name: name.to_string(),
}
}
}
"chatlist" | "chatwho" | "chatinfo" => {
let name = args.split_whitespace().next().unwrap_or("");
if name.is_empty() {
ParsedChat::Unknown // the bare joined-channel listing is P6's (needs the list)
} else {
ParsedChat::ChatList {
name: name.to_string(),
}
}
}
"afk" => ParsedChat::AfkDnd {
kind: ChatKind::Afk,
msg: args.to_string(),
},
"dnd" => ParsedChat::AfkDnd {
kind: ChatKind::Dnd,
msg: args.to_string(),
},
"random" | "rand" | "rnd" | "roll" => {
let mut nums = args
.split_whitespace()
.filter_map(|w| w.parse::<u32>().ok());
let (a, b) = (nums.next(), nums.next());
let (min, max) = match (a, b) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (1, a),
_ => (1, 100),
};
ParsedChat::Random { min, max }
}
"played" => ParsedChat::Played,
"shot" => ParsedChat::Shot,
"liquid" => ParsedChat::Liquid,
"reaction" | "react" => ParsedChat::Reaction {
name: slash_target_name(args),
},
// The party membership commands (decision 0434; aliases quoted from GlobalStrings'
// SLASH_INVITE/UNINVITE/PROMOTE 1-8, lines 3660-3780).
"i" | "inv" | "invite" => ParsedChat::Invite {
name: slash_target_name(args),
},
"u" | "un" | "kick" | "uninvite" => ParsedChat::Uninvite {
name: slash_target_name(args),
},
"pr" | "promote" => ParsedChat::Promote {
name: slash_target_name(args),
},
// The duel verbs (decision 0633; aliases quoted from GlobalStrings' SLASH_DUEL 1-2 and
// SLASH_DUEL_CANCEL 1-6, lines 3563-3569).
"duel" => ParsedChat::Duel {
name: slash_target_name(args),
},
"forfeit" | "concede" | "yield" => ParsedChat::Forfeit,
// The PvP flag (decision 0646; GlobalStrings' SLASH_PVP 1-2, lines 3718-3719 — both
// aliases are literally "/pvp").
"pvp" => ParsedChat::Pvp,
// The social verbs (decision 0668; aliases quoted from GlobalStrings' SLASH_WHO 1-2,
// SLASH_FRIENDS 1-4, SLASH_REMOVEFRIEND 1-4, SLASH_IGNORE 1-2, SLASH_UNIGNORE 1-2 —
// lines 3585-3794). The argument is passed WHOLE: `/who`'s is a filter expression
// (`z-"Elwynn Forest" 1-10`), not a name, so `slash_target_name` must not touch it.
"who" => ParsedChat::Social {
verb: SocialVerb::Who,
arg: args.trim().to_string(),
},
"friend" | "friends" => ParsedChat::Social {
verb: SocialVerb::Friends,
arg: args.trim().to_string(),
},
"removefriend" | "remfriend" => ParsedChat::Social {
verb: SocialVerb::RemoveFriend,
arg: args.trim().to_string(),
},
"ignore" => ParsedChat::Social {
verb: SocialVerb::Ignore,
arg: args.trim().to_string(),
},
"unignore" => ParsedChat::Social {
verb: SocialVerb::Unignore,
arg: args.trim().to_string(),
},
"h" | "help" | "?" => ParsedChat::Help,
// Not a recognized command — the EmotesText lookup against the whole slash-line
// (`/wave`, `/lol`, …), exactly as before.
_ => resolve_emote(rest).map_or(ParsedChat::Unknown, ParsedChat::TextEmote),
}
}
/// The Enter-path type switch (`ChatEdit_ParseText(send=1)` runs the same conversion the live
/// parse does, but without the live parse's trailing-space requirement — "/g hi" + Enter
/// converts and sends in one stroke; "/g" alone converts and commits the sticky).
pub(super) fn parse_enter_type_switch(
channels: &super::edit::ChannelState,
text: &str,
) -> Option<(super::edit::TypeSwitch, String)> {
use super::edit::{SendType, TypeSwitch};
let rest = text.strip_prefix('/')?;
let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
let lower = cmd.to_ascii_lowercase();
// `/2 hi` and `/c world hi` convert-and-send on the enter path too.
if let Some(switch) = super::edit::channel_switch(channels, &lower, args) {
return Some(switch);
}
if ["w", "whisper", "t", "tell", "send"].contains(&lower.as_str()) {
let (target, remainder) = args.split_once(' ').unwrap_or((args, ""));
if target.is_empty() || target.starts_with('|') || remainder.trim().is_empty() {
return None; // needs a name AND a message on the enter path
}
return Some((
TypeSwitch::Whisper(target.to_string()),
remainder.to_string(),
));
}
let t = match lower.as_str() {
"s" | "say" => SendType::Say,
"y" | "yell" | "sh" | "shout" => SendType::Yell,
"e" | "em" | "emote" | "me" => SendType::Emote,
"p" | "party" => SendType::Party,
"raid" | "ra" | "rsay" => SendType::Raid,
"rw" => SendType::RaidWarning,
"g" | "gc" | "gu" | "guild" => SendType::Guild,
"o" | "osay" => SendType::Officer,
"bg" | "battleground" => SendType::Battleground,
_ => return None,
};
Some((TypeSwitch::Plain(t), args.to_string()))
}
/// `/chattest` (the 0288 instrument): one synthetic line of every renderable form through the
/// real event pipeline — kinds, flags, the language header, channel prefixes, notices, and both
/// link forms (item + player), so formats/colors/links verify in one screen.
+406
View File
@@ -0,0 +1,406 @@
//! What a submitted line MEANS — the grammar half of the chat's outbound side (decision 0288 P5,
//! re-based on the command table in decision 0881). Three resolvers, in the reference's own
//! `ChatEdit_ParseText` order: the chat-TYPE switch ([`parse_enter_type_switch`] — `/say`, `/w`,
//! a channel number), then the ACTION table ([`parse_line`] → [`ParsedChat`], every
//! `SlashCmdList` command benilla registers plus the `/wave`-style emotes), then nothing, which
//! is `HELP_TEXT_SIMPLE`. Doing anything about the result is [`super`]'s (the drain).
//!
//! No aliases live here: which strings reach which arm is [`super::super::commands`]'s table,
//! built from the shipped `GlobalStrings.lua`.
use crate::net::ChatKind;
use crate::ui_chat::commands::{Command, DevCmd, SlashCommands, SlashIndex};
/// Escape a player-typed string for embedding in a Lua double-quoted literal: backslashes and
/// quotes are escaped, and the two line terminators are dropped rather than escaped (nothing a
/// slash command means can contain them, and a stray one would end the statement).
pub(in crate::ui_chat) fn escape_lua_string(s: &str) -> String {
s.chars()
.filter(|c| *c != '\n' && *c != '\r')
.flat_map(|c| {
let escaped = matches!(c, '\\' | '"');
escaped
.then_some('\\')
.into_iter()
.chain(std::iter::once(c))
})
.collect()
}
/// Which social slash command was typed — the selector for the Lua body it runs
/// ([`ParsedChat::Social`], decision 0668).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::ui_chat) enum SocialVerb {
/// `/who [filter]` — SLASH_WHO 1-2.
Who,
/// `/friends [name]` (aliases `/friend`) — SLASH_FRIENDS 1-4.
Friends,
/// `/removefriend <name>` (alias `/remfriend`) — SLASH_REMOVEFRIEND 1-4.
RemoveFriend,
/// `/ignore [name]` — SLASH_IGNORE 1-2. Toggles: an ignored name is un-ignored.
Ignore,
/// `/unignore [name]` — SLASH_UNIGNORE 1-2.
Unignore,
}
impl SocialVerb {
/// The Lua function in `FriendsFrame.xml` holding this verb's reference body.
pub(in crate::ui_chat) fn lua_fn(self) -> &'static str {
match self {
Self::Who => "BenillaSlashWho",
Self::Friends => "BenillaSlashFriends",
Self::RemoveFriend => "BenillaSlashRemoveFriend",
Self::Ignore => "BenillaSlashIgnore",
Self::Unignore => "BenillaSlashUnignore",
}
}
}
/// What a submitted, already-trimmed chat line resolves to (see [`parse_line`]). Kept separate from
/// [`ClientCommand`] so the parser is unit-testable without a live [`crate::sound::EmoteSounds`]
/// resource or a network channel.
#[derive(Debug, Clone, PartialEq)]
pub(in crate::ui_chat) enum ParsedChat {
/// `/r [text]` — reply to the last received tell ([`crate::ui_chat::edit::ChatEditState::last_tell`]).
Reply { text: String },
/// `/join <name> [password]` (aliases /channel /chan — SLASH_JOIN).
Join { name: String, password: String },
/// `/leave <name>` (aliases /chatleave /chatexit).
Leave { name: String },
/// `/chatlist <name>` (aliases /chatwho /chatinfo) — the member roster ask.
ChatList { name: String },
/// `/afk [msg]` / `/dnd [msg]` — the away toggles (CHAT_MSG_AFK/DND sends).
AfkDnd { kind: ChatKind, msg: String },
/// `/random [min] [max]` (aliases /rand /rnd /roll): bare = 1-100, one number = 1-N.
Random { min: u32, max: u32 },
/// `/played` — CMSG_PLAYED_TIME.
Played,
/// `/shot` — the director's framing instrument (decision 0600): dump the CURRENT world-camera
/// pose as a ready-to-paste capture `Scenario` (raw WoW eye/look + the rendered game minute)
/// into chat and `config_base()`/shots.txt. Client-local, no wire traffic — how a chosen spot
/// travels from the director's eye to the golden set as exact numbers.
Shot,
/// `/liquid` — the swim diagnostic (decision 0634 follow-up): dump the interior claim, the
/// resolved liquid verdict, and every candidate footprint over the player's feet. Client-local,
/// no wire traffic. Built when the canal-tunnel "swim in air" survived the first fix — the
/// question "which surface is claiming this spot, and from which file" had no instrument.
Liquid,
/// `/reaction [name]` — the attackability diagnostic: every input the reaction ladder judges
/// the subject on, the rung that decided, and the resulting `can_attack` verdict. Bare uses
/// the current target; a name resolves a streamed player (what a scripted probe can reach).
Reaction { name: Option<String> },
/// `/help` (aliases /h /?) — the command summary.
Help,
/// A `/name` line that resolved in `EmotesText` (`/wave` → text id 101) — sent as
/// `CMSG_TEXT_EMOTE` targeted at the current selection.
TextEmote(u32),
/// The `/castvis` **dev instrument** (decision 0099 phase 2): synthesize a cast edge locally
/// — the exact [`crate::creature_anim::CastEvent`] the wire would produce — on the selection
/// (else self), no server round-trip. Runtime-available like every current instrument
/// (`ui_pass::demo_enabled`'s note: decision 0026's compile-time `dev` feature is the recorded
/// target seam, not yet built); it moves behind that feature when 0026 phase 1 lands.
CastVis {
spell_id: u32,
kind: crate::creature_anim::CastEventKind,
},
/// A command whose reference handler is a **one-line call** into FrameXML — `/trade`
/// (`InitiateTrade("target")`), `/inspect` (`InspectUnit("target")`), the loot-method trio
/// (`SetLootMethod(…)`), and `/script`/`/run` (the ref's `RunScript(msg)`, where the typed text
/// IS the chunk). Running the reference's own call is the 0668 posture: the panel-opening,
/// permission and error behaviour stays in the FrameXML that owns it instead of being
/// re-derived in Rust.
Lua { body: String },
/// `/quit` `/exit` — the reference's `Quit()`: leave the game entirely, through the same
/// session queue (and the same confirmation/countdown) the game menu's Exit button uses.
Quit,
/// `/logout` — leave the world back to character select (`CMSG_LOGOUT_REQUEST`, decision 0193);
/// the vanilla client's own command. The confirmed round-trip (`SMSG_LOGOUT_COMPLETE`) flips
/// [`crate::char_select::ClientState`] back to the roster screen.
Logout,
/// `/chattest` — the 0288 instrument: pump one synthetic line of every kind/notice/link form
/// through the REAL router+composer, so the whole surface is eyeballable in one screen with
/// no server choreography. Dev-runtime like `/castvis` (decision 0026's compile-time gate is
/// the recorded target seam).
ChatTest,
/// `/invite` `/inv` `/i` — party invite by name (`CMSG_GROUP_INVITE`). `None` = bare, which
/// falls back to the selected PLAYER's name (the ref's `GetSlashCmdTarget` law,
/// ChatFrame.lua:650-658 — no name and no player target is a silent no-op).
Invite { name: Option<String> },
/// `/uninvite` `/un` `/u` `/kick` — kick by name (`CMSG_GROUP_UNINVITE`), same
/// name-or-target law.
Uninvite { name: Option<String> },
/// `/promote` `/pr` — hand leadership over (`CMSG_GROUP_SET_LEADER`), same name-or-target
/// law; the 1.12 wire takes a guid, resolved against the roster at dispatch.
Promote { name: Option<String> },
/// `/duel [name]` — challenge a player (the duel spell cast at them). `None` = bare, which
/// falls back to the selected PLAYER exactly like the party commands do — the ref's
/// `SlashCmdList["DUEL"]` is `if GetSlashCmdTarget(msg) then StartDuel(...)`, so no name and
/// no player target is a silent no-op.
Duel { name: Option<String> },
/// `/forfeit` `/concede` `/yield` — `CancelDuel()`. Takes no argument and needs no state
/// check: one opcode covers decline/cancel/forfeit and the server reads the intent.
Forfeit,
/// `/pvp` — `TogglePVP()` (decision 0646 §3). Takes no argument: the binding has no state
/// form, and the server reads the toggle from our current preference.
Pvp,
/// The social verbs (decision 0668) — `/who`, `/friends`, `/removefriend`, `/ignore`,
/// `/unignore`. Each carries the raw argument (`/who`'s is a whole filter string, not a
/// name), and each runs the reference's OWN `SlashCmdList` body, transcribed into
/// `FriendsFrame.xml` as `BenillaSlash*`: those bodies do more than send (a bare `/who`
/// opens the panel and fills its edit box, a bare `/ignore` opens the ignore list), and
/// keeping them in the FrameXML is what stops that behaviour being re-derived here.
Social { verb: SocialVerb, arg: String },
/// `/partytest [lead|invite|mark|off]` — the party-frame dev instrument (decision 0434, the
/// `/chattest` pattern): a synthetic roster through the real apply path (`lead` = the same
/// roster with US leading, for the leader-only popup rows), a fake pending invite for the
/// popup, a local skull on the current target (the mark renders without a server echo), or
/// clear. While the roster is synthetic the drain runs in SANDBOX mode
/// ([`crate::ui_party::GroupState::test`]): the popup's group-mutating rows — promote,
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless.
PartyTest { arg: String },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's
/// player-target fallback.
fn slash_target_name(args: &str) -> Option<String> {
let name = args.split_whitespace().next().unwrap_or("");
(!name.is_empty()).then(|| name.to_string())
}
/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev
/// commands…) through the boot-built command table ([`super::commands`], decision 0881). The
/// chat-TYPE commands (`/say`-family, `/w`, `/r`) never reach here on the send path —
/// [`parse_enter_type_switch`] and the reply arm consume them first; a plain no-slash line sends as
/// the box's current type ([`drain_chat_input`]). A non-slash line reaching this fn (unit tests
/// only) is [`ParsedChat::Unknown`].
///
/// The command/argument split is `ChatEdit_ParseText`'s (ChatFrame.lua l.2161-2168): the command is
/// the FIRST word, the argument is everything after that first space. That is why `/wave Bob` is a
/// `/wave` carrying an argument — before 0881 the emote arm matched the *whole* line against
/// `EmotesText`, so every emote typed with anything after it was an unknown command.
pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> ParsedChat {
let Some(rest) = line.strip_prefix('/') else {
return ParsedChat::Unknown;
};
let rest = rest.trim();
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let args = args.trim();
match table.lookup(cmd) {
Some(Command::Slash(index)) => slash_command(index, args),
// The alias table did the `DoEmote(token)` resolve at boot: `/lol` arrives as LAUGH's id.
Some(Command::Emote { text_id }) => ParsedChat::TextEmote(text_id),
Some(Command::Dev(dev)) => dev_command(dev, args),
// HELP_TEXT_SIMPLE's case — a command benilla registers no handler for is indistinguishable
// from one the reference never had, which is the honest answer for both.
None => ParsedChat::Unknown,
}
}
/// The per-command argument grammar. Each arm is the reference handler's own body reduced to what
/// it does with `msg` — the aliases that reach it are the table's business, never this function's.
fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
use SlashIndex as S;
match index {
S::Reply => ParsedChat::Reply {
text: args.to_string(),
},
S::Join => {
let (name, password) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
if name.is_empty() {
// CHAT_JOIN_HELP (the ref's bare-/join reply) rides the Unknown help line.
ParsedChat::Unknown
} else {
ParsedChat::Join {
name: name.to_string(),
password: password.trim().to_string(),
}
}
}
S::Leave => match args.split_whitespace().next() {
Some(name) => ParsedChat::Leave {
name: name.to_string(),
},
None => ParsedChat::Unknown,
},
S::ListChannel => match args.split_whitespace().next() {
Some(name) => ParsedChat::ChatList {
name: name.to_string(),
},
// The bare joined-channel listing is P6's (it needs the list).
None => ParsedChat::Unknown,
},
S::ChatAfk => ParsedChat::AfkDnd {
kind: ChatKind::Afk,
msg: args.to_string(),
},
S::ChatDnd => ParsedChat::AfkDnd {
kind: ChatKind::Dnd,
msg: args.to_string(),
},
S::Random => {
let mut nums = args
.split_whitespace()
.filter_map(|w| w.parse::<u32>().ok());
let (a, b) = (nums.next(), nums.next());
let (min, max) = match (a, b) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (1, a),
_ => (1, 100),
};
ParsedChat::Random { min, max }
}
S::Played => ParsedChat::Played,
S::Help => ParsedChat::Help,
S::Logout => ParsedChat::Logout,
S::Quit => ParsedChat::Quit,
// The party membership commands (decision 0434) and the duel verbs (0633): a bare command
// falls back to the selected PLAYER (`GetSlashCmdTarget`).
S::Invite => ParsedChat::Invite {
name: slash_target_name(args),
},
S::Uninvite => ParsedChat::Uninvite {
name: slash_target_name(args),
},
S::Promote => ParsedChat::Promote {
name: slash_target_name(args),
},
S::Duel => ParsedChat::Duel {
name: slash_target_name(args),
},
S::DuelCancel => ParsedChat::Forfeit,
S::Pvp => ParsedChat::Pvp,
// The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter
// expression (`z-"Elwynn Forest" 1-10`), not a name.
S::Who => ParsedChat::Social {
verb: SocialVerb::Who,
arg: args.to_string(),
},
S::Friends => ParsedChat::Social {
verb: SocialVerb::Friends,
arg: args.to_string(),
},
S::RemoveFriend => ParsedChat::Social {
verb: SocialVerb::RemoveFriend,
arg: args.to_string(),
},
S::Ignore => ParsedChat::Social {
verb: SocialVerb::Ignore,
arg: args.to_string(),
},
S::Unignore => ParsedChat::Social {
verb: SocialVerb::Unignore,
arg: args.to_string(),
},
// The one-line reference bodies over globals benilla already implements (decision 0881,
// the 0668 posture): `InitiateTrade("target")`, `InspectUnit("target")`,
// `SetLootMethod(...)`, `RunScript(msg)`. Running the reference's own call keeps the
// panel-opening/permission behaviour in the FrameXML that owns it.
S::Trade => ParsedChat::Lua {
body: "InitiateTrade(\"target\")".into(),
},
S::Inspect => ParsedChat::Lua {
body: "InspectUnit(\"target\")".into(),
},
S::LootFfa => ParsedChat::Lua {
body: "SetLootMethod(\"freeforall\")".into(),
},
S::LootRoundRobin => ParsedChat::Lua {
body: "SetLootMethod(\"roundrobin\")".into(),
},
// The ref's LOOT_MASTER is a no-op without a name/target (`if GetSlashCmdTarget(msg)`).
S::LootMaster => match slash_target_name(args) {
Some(name) => ParsedChat::Lua {
body: format!(
"SetLootMethod(\"master\", \"{}\")",
escape_lua_string(&name)
),
},
None => ParsedChat::Unknown,
},
// `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped.
S::Script => {
if args.is_empty() {
ParsedChat::Unknown
} else {
ParsedChat::Lua {
body: args.to_string(),
}
}
}
}
}
/// Benilla's own instruments' grammar ([`DevCmd`] — no reference strings behind these).
fn dev_command(dev: DevCmd, args: &str) -> ParsedChat {
match dev {
// `/castvis <spell_id> [go|fail]` — bare = Start (the precast hold begins), `go` = the
// release, `fail` = the reap. Malformed → Unknown (dropped loudly).
DevCmd::CastVis => {
use crate::creature_anim::CastEventKind;
let mut words = args.split_whitespace();
let Some(spell_id) = words.next().and_then(|w| w.parse::<u32>().ok()) else {
return ParsedChat::Unknown;
};
let kind = match words.next() {
None => CastEventKind::Start,
Some(w) if w.eq_ignore_ascii_case("go") => CastEventKind::Go,
Some(w) if w.eq_ignore_ascii_case("fail") => CastEventKind::Fail,
Some(_) => return ParsedChat::Unknown,
};
ParsedChat::CastVis { spell_id, kind }
}
DevCmd::ChatTest => ParsedChat::ChatTest,
DevCmd::PartyTest => ParsedChat::PartyTest {
arg: args.to_ascii_lowercase(),
},
DevCmd::Shot => ParsedChat::Shot,
DevCmd::Liquid => ParsedChat::Liquid,
DevCmd::Reaction => ParsedChat::Reaction {
name: slash_target_name(args),
},
}
}
/// The Enter-path type switch (`ChatEdit_ParseText(send=1)` runs the same conversion the live
/// parse does, but without the live parse's trailing-space requirement — "/g hi" + Enter
/// converts and sends in one stroke; "/g" alone converts and commits the sticky).
pub(in crate::ui_chat) fn parse_enter_type_switch(
channels: &crate::ui_chat::edit::ChannelState,
text: &str,
) -> Option<(crate::ui_chat::edit::TypeSwitch, String)> {
use crate::ui_chat::edit::{SendType, TypeSwitch};
let rest = text.strip_prefix('/')?;
let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
let lower = cmd.to_ascii_lowercase();
// `/2 hi` and `/c world hi` convert-and-send on the enter path too.
if let Some(switch) = crate::ui_chat::edit::channel_switch(channels, &lower, args) {
return Some(switch);
}
if ["w", "whisper", "t", "tell", "send"].contains(&lower.as_str()) {
let (target, remainder) = args.split_once(' ').unwrap_or((args, ""));
if target.is_empty() || target.starts_with('|') || remainder.trim().is_empty() {
return None; // needs a name AND a message on the enter path
}
return Some((
TypeSwitch::Whisper(target.to_string()),
remainder.to_string(),
));
}
let t = match lower.as_str() {
"s" | "say" => SendType::Say,
"y" | "yell" | "sh" | "shout" => SendType::Yell,
"e" | "em" | "emote" | "me" => SendType::Emote,
"p" | "party" => SendType::Party,
"raid" | "ra" | "rsay" => SendType::Raid,
"rw" => SendType::RaidWarning,
"g" | "gc" | "gu" | "guild" => SendType::Guild,
"o" | "osay" => SendType::Officer,
"bg" | "battleground" => SendType::Battleground,
_ => return None,
};
Some((TypeSwitch::Plain(t), args.to_string()))
}
+195 -37
View File
@@ -2,7 +2,7 @@ use crate::net::ChatKind;
use super::event::{default_color, ChatEvent, ChatEventKind as K};
use super::frames::compose;
use super::input::{emote_send_eligible, parse_line, ParsedChat};
use super::input::{emote_send_eligible, ParsedChat};
/// A player-line event (the wire bridge's output shape) — sender resolved, optional flag.
fn ev(kind: K, text: &str, sender: &str) -> ChatEvent {
@@ -251,9 +251,41 @@ fn colors_match_the_shipped_table() {
// ── the submitted-line grammar (0288 P5): type switches + action commands ──────────────────
/// A stub `EmotesText` resolver: only "wave" resolves, to id 101 (mirrors `/wave`'s real id).
fn stub_emotes(name: &str) -> Option<u32> {
(name == "wave").then_some(101)
/// The grammar fixture: a command table built from a *stub* of the reference's alias strings, in
/// the same `SLASH_<INDEX><n>` / `EMOTE<i>_CMD<j>` shape the shipped `GlobalStrings.lua` has. The
/// aliases here are fixture data for the ARGUMENT grammar; that the real ones all resolve is
/// [`real_alias_table_resolves_the_shipped_commands`]'s job, on the real files.
fn stub_table() -> super::commands::SlashCommands {
const STRINGS: &[(&str, &str)] = &[
("SLASH_JOIN1", "/join"),
("SLASH_LEAVE1", "/leave"),
("SLASH_LIST_CHANNEL1", "/chatlist"),
("SLASH_CHAT_AFK1", "/afk"),
("SLASH_RANDOM1", "/random"),
("SLASH_RANDOM2", "/roll"),
("SLASH_PLAYED1", "/played"),
("SLASH_HELP1", "/help"),
("SLASH_PVP1", "/pvp"),
("SLASH_REPLY1", "/r"),
("SLASH_LOGOUT1", "/logout"),
("SLASH_LOGOUT2", "/camp"),
("SLASH_QUIT1", "/quit"),
("SLASH_TRADE1", "/trade"),
("SLASH_SCRIPT1", "/script"),
// One emote index, in the two-table shape: the alias, and the token it resolves through.
("EMOTE1_CMD1", "/wave"),
("EMOTE1_CMD2", "/hello"), // an alias that is NOT the token — the 0881 class of bug
("EMOTE1_TOKEN", "WAVE"),
];
super::commands::SlashCommands::build(
|key| {
STRINGS
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
},
|token| (token == "WAVE").then_some(101),
)
}
/// The Enter-path type switch (send path — no trailing-space requirement).
@@ -348,111 +380,237 @@ fn tell_ring_dedups_and_cycles() {
#[test]
fn action_commands_parse() {
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(
parse_line("/join world secret", stub_emotes),
parse_line("/join world secret"),
ParsedChat::Join {
name: "world".into(),
password: "secret".into(),
}
);
assert_eq!(
parse_line("/leave world", stub_emotes),
parse_line("/leave world"),
ParsedChat::Leave {
name: "world".into()
}
);
assert_eq!(
parse_line("/chatlist world", stub_emotes),
parse_line("/chatlist world"),
ParsedChat::ChatList {
name: "world".into()
}
);
assert_eq!(
parse_line("/afk farming", stub_emotes),
parse_line("/afk farming"),
ParsedChat::AfkDnd {
kind: ChatKind::Afk,
msg: "farming".into(),
}
);
assert_eq!(parse_line("/roll"), ParsedChat::Random { min: 1, max: 100 });
assert_eq!(
parse_line("/roll", stub_emotes),
ParsedChat::Random { min: 1, max: 100 }
);
assert_eq!(
parse_line("/random 50", stub_emotes),
parse_line("/random 50"),
ParsedChat::Random { min: 1, max: 50 }
);
assert_eq!(
parse_line("/random 2 8", stub_emotes),
parse_line("/random 2 8"),
ParsedChat::Random { min: 2, max: 8 }
);
assert_eq!(parse_line("/played", stub_emotes), ParsedChat::Played);
assert_eq!(parse_line("/help", stub_emotes), ParsedChat::Help);
assert_eq!(parse_line("/played"), ParsedChat::Played);
assert_eq!(parse_line("/help"), ParsedChat::Help);
// /pvp takes no argument (decision 0646 §3): the binding has no state form, so a trailing
// word is ignored rather than read as a target.
assert_eq!(parse_line("/pvp", stub_emotes), ParsedChat::Pvp);
assert_eq!(parse_line("/pvp on", stub_emotes), ParsedChat::Pvp);
assert_eq!(parse_line("/pvp"), ParsedChat::Pvp);
assert_eq!(parse_line("/pvp on"), ParsedChat::Pvp);
// /r rides its own arm (the reply state lives on ChatEditState).
assert_eq!(
parse_line("/r hey", stub_emotes),
parse_line("/r hey"),
ParsedChat::Reply { text: "hey".into() }
);
}
#[test]
fn emote_names_fall_through_to_the_dbc_lookup() {
assert_eq!(parse_line("/wave", stub_emotes), ParsedChat::TextEmote(101));
assert_eq!(parse_line("/nosuch", stub_emotes), ParsedChat::Unknown);
fn emote_aliases_resolve_through_the_table() {
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(parse_line("/wave"), ParsedChat::TextEmote(101));
// The 0881 fix in one line: an alias that is NOT its token's `EmotesText` name resolves too.
// `/hello` (token WAVE) is the shape `/lol` (token LAUGH) has in the shipped table — before
// the table, matching on the DBC name alone left 61 such commands unresolvable.
assert_eq!(parse_line("/hello"), ParsedChat::TextEmote(101));
// An emote takes an argument (`DoEmote(token, msg)`): the command is the first word only.
assert_eq!(parse_line("/wave Bob"), ParsedChat::TextEmote(101));
assert_eq!(parse_line("/nosuch"), ParsedChat::Unknown);
}
#[test]
fn logout_and_camp_parse() {
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
for line in ["/logout", "/camp", "/LOGOUT", "/logout now"] {
assert_eq!(parse_line(line, stub_emotes), ParsedChat::Logout);
assert_eq!(parse_line(line), ParsedChat::Logout);
}
assert_eq!(parse_line("/quit"), ParsedChat::Quit);
}
#[test]
fn one_line_reference_bodies_run_in_the_vm() {
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
// `/trade` is the ref's `InitiateTrade("target")`, verbatim.
assert_eq!(
parse_line("/trade"),
ParsedChat::Lua {
body: "InitiateTrade(\"target\")".into()
}
);
// `/script` runs the typed text AS the chunk (the ref's `RunScript(msg)`); bare is a no-op.
assert_eq!(
parse_line("/script Print(\"hi\")"),
ParsedChat::Lua {
body: "Print(\"hi\")".into()
}
);
assert_eq!(parse_line("/script"), ParsedChat::Unknown);
}
#[test]
fn castvis_parses_id_and_phase() {
use crate::creature_anim::CastEventKind;
let none = |_: &str| None;
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(
parse_line("/castvis 133", none),
parse_line("/castvis 133"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Start
}
);
assert_eq!(
parse_line("/castvis 133 go", none),
parse_line("/castvis 133 go"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Go
}
);
assert_eq!(
parse_line("/castvis 689 FAIL", none),
parse_line("/castvis 689 FAIL"),
ParsedChat::CastVis {
spell_id: 689,
kind: CastEventKind::Fail
}
);
assert_eq!(parse_line("/castvis", none), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis abc", none), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis 133 nope", none), ParsedChat::Unknown);
}
#[test]
fn unresolved_slash_line_falls_back_to_emote_lookup() {
assert_eq!(parse_line("/wave", stub_emotes), ParsedChat::TextEmote(101));
assert_eq!(parse_line("/castvis"), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis abc"), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis 133 nope"), ParsedChat::Unknown);
}
#[test]
fn unknown_slash_command_is_dropped_not_said_aloud() {
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
// The regression this grammar exists to fix: `/yell` used to literally SAY "/yell hello" —
// any unresolved slash-line must never fall through to plain chat.
assert_eq!(parse_line("/dancemove", stub_emotes), ParsedChat::Unknown);
assert_eq!(parse_line("/frobnicate", stub_emotes), ParsedChat::Unknown);
assert_eq!(parse_line("/dancemove"), ParsedChat::Unknown);
assert_eq!(parse_line("/frobnicate"), ParsedChat::Unknown);
}
/// The RUNTIME leg on the real data (the `every_mount_key_resolves…` pattern): build the table the
/// way boot does — the shipped `GlobalStrings.lua` and `ChatFrame.lua`'s token table executed into
/// a real VM, joined to the real `EmotesText.dbc` — and assert the commands 0881 was opened for.
/// Skips without client data.
#[test]
fn real_alias_table_resolves_the_shipped_commands() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
for file in ["GlobalStrings.lua", "ChatFrame.lua"] {
let src = chain
.read_file(&format!("Interface\\FrameXML\\{file}"))
.expect("FrameXML file in the chain");
let src = String::from_utf8_lossy(&src).into_owned();
// GlobalStrings runs whole (it is only assignments); ChatFrame contributes its token
// table alone, through the production filter.
let src = if file == "ChatFrame.lua" {
src.lines()
.map(str::trim)
.filter(|l| crate::ui_script::is_emote_token_line(l))
.collect::<Vec<_>>()
.join("\n")
} else {
src
};
s.run(&src).expect("runs clean");
}
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
let globals = s.lua().globals();
let table = super::commands::SlashCommands::build(
|name| globals.get::<String>(name).ok().filter(|v| !v.is_empty()),
|token| cat.text_id(token),
);
let parse_line = |line: &str| super::input::parse_line(&table, line);
// The reported symptom: `/sit` resolves to the SIT text emote (EmotesText id 86), whose
// `Emotes.dbc` row (13, STATE_SIT) is the posture emote that sets stand state 1.
assert_eq!(parse_line("/sit"), ParsedChat::TextEmote(86));
assert_eq!(
cat.text_emote(86).and_then(|e| cat.posture_state(e)),
Some(1)
);
// Every posture command the shipped tables expose, with the state it sets.
for (line, state) in [
("/stand", 0),
("/sit", 1),
("/sleep", 3),
("/liedown", 3),
("/kneel", 8),
] {
let ParsedChat::TextEmote(text_id) = parse_line(line) else {
panic!("{line} is an emote");
};
let posture = cat.text_emote(text_id).and_then(|e| cat.posture_state(e));
assert_eq!(posture, Some(state), "{line} sets stand state {state}");
}
// The dead-alias class 0881 found: an alias that differs from its token's DBC name. These all
// returned "Type '/help'…" before the table.
for line in [
"/lol",
"/hi",
"/ty",
"/thanks",
"/congrats",
"/sorry",
"/yes",
"/bravo",
"/weep",
"/goodbye",
"/pizza",
"/strong",
] {
assert!(
matches!(parse_line(line), ParsedChat::TextEmote(_)),
"{line} resolves to an emote"
);
}
// …and the four names the reference has NO command for, which the DBC-name match used to
// accept. `/follow` is the sharp one: it fired a text emote where the real client's
// `SlashCmdList["FOLLOW"]` follows your target (benilla has no follow yet, so it is honestly
// unknown rather than wrongly an emote).
for line in ["/follow", "/joke", "/puzzle", "/attackmytarget"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
}
// A command whose handler benilla does not register answers like any unknown command.
assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown);
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **55 distinct
// aliases** across the 28 registered `SlashCmdList` indices.
assert_eq!(table.counts(), (55, 225), "(slash, emote) aliases");
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
+156
View File
@@ -0,0 +1,156 @@
//! The mirror-timer feed: breath / fatigue / feign-death off the wire → FrameXML events
//! (decision 0874).
//!
//! The net bridge queues [`MirrorTimerEdge`]s and the drain fires the reference client's
//! FrameScript events into the script VM — `MIRROR_TIMER_START` / `_PAUSE` / `_STOP`, the exact
//! contract `assets/ui/MirrorTimer.xml` (the transcribed 1.12 `MirrorTimer1/2/3`) registers for.
//! The bars themselves are the reference's: the frame stores the value and integrates
//! `value + scale * elapsed` every OnUpdate, so a packet every few seconds is enough to paint a
//! smooth countdown.
//!
//! **The client computes nothing here.** Breath and fatigue are server state — vmangos's
//! `Player::UpdateMirrorTimers` runs them off its own liquid checks (`IsUnderwater`,
//! `IsInHighSea`) and ships a value + a signed rate. That is why there is no local "am I
//! underwater" predicate in this module and should not be one: a second, disagreeing authority
//! is exactly how a bar ends up drifting from the drowning damage that follows it.
//!
//! The `ui_cast::CastBarFeed` pattern throughout — one queue, one drain, no per-frame work.
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use benilla_protocol::messages::{MirrorTimerKind, MirrorTimerStart};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// One mirror-timer edge off the wire, queued by the net bridge for the bars.
#[derive(Debug, Clone, Copy)]
pub(crate) enum MirrorTimerEdge {
/// `SMSG_START_MIRROR_TIMER` — start, or wholly re-state, one timer. The server re-sends this
/// on every change (direction, remaining, frozen), so it arrives repeatedly for one bar.
Start(MirrorTimerStart),
/// `SMSG_PAUSE_MIRROR_TIMER` — freeze/unfreeze. vmangos never sends it (it substitutes a full
/// `Start`), but a server that does must not be ignored.
Pause { kind: u32, paused: bool },
/// `SMSG_STOP_MIRROR_TIMER` — that timer is over; its bar hides.
Stop { kind: u32 },
}
/// The net bridge's mirror-timer queue (the [`crate::ui_cast::CastBarFeed`] pattern).
#[derive(Resource, Default)]
pub(crate) struct MirrorTimerFeed(pub(crate) Vec<MirrorTimerEdge>);
/// The FrameScript name the reference passes as **arg1** for each timer type — the key its
/// `MirrorTimerColors` table is indexed by, and the stem of the caption lookup below.
///
/// The client holds these as a 3-entry table indexed by the wire's `timerType`
/// (`WoW.exe`: `"EXHAUSTION"` @`0x460520`, `"BREATH"` @`0x46052c`, `"FEIGNDEATH"` @`0x460534`,
/// contiguous and in the server's `MirrorTimer::Type` order). Note the type-0 name is
/// `EXHAUSTION`, not the server's own word for it (`FATIGUE`) — the two ends disagree on the
/// name of the same timer, and it is the *client's* word that the Lua is keyed by.
fn script_name(kind: MirrorTimerKind) -> &'static str {
match kind {
MirrorTimerKind::Fatigue => "EXHAUSTION",
MirrorTimerKind::Breath => "BREATH",
MirrorTimerKind::FeignDeath => "FEIGNDEATH",
}
}
/// The bar's caption — **arg6** of `MIRROR_TIMER_START`.
///
/// The reference builds the global-string name by formatting `"%s_LABEL"` (`WoW.exe` @`0x460540`,
/// sitting immediately after the name table above) with the timer name and looking the result up
/// in the FrameScript globals. The 1.12 `GlobalStrings.lua` defines exactly two of the three —
/// `BREATH_LABEL = "Breath"` and `EXHAUSTION_LABEL = "Fatigue"`, each with the comment "Used as
/// the label for the … status bar" — and **no** `FEIGNDEATH_LABEL`, so that timer's caption comes
/// back empty in the reference too.
///
/// The strings are inlined here rather than looked up through the VM for the same reason
/// `CastingBar.xml` inlines `FAILED`/`INTERRUPTED`: benilla loads no `GlobalStrings.lua` yet. When
/// it does, this becomes the lookup the reference does.
fn caption(kind: MirrorTimerKind) -> &'static str {
match kind {
MirrorTimerKind::Fatigue => "Fatigue",
MirrorTimerKind::Breath => "Breath",
// No FEIGNDEATH_LABEL exists in the 1.12 GlobalStrings — the reference shows no caption.
MirrorTimerKind::FeignDeath => "",
}
}
/// Drain the queue into the script VM, one FrameScript event per edge.
///
/// A `kind` the client has no bar for is dropped: the server's own `NUM_CLIENT_TIMERS` gate means
/// vanilla never sends one (its fourth, `ENVIRONMENTAL`, drives lava damage with no bar), and the
/// reference would index past the end of a 3-entry table if one arrived.
fn feed_mirror_timers(script: Option<NonSendMut<UiScript>>, mut feed: ResMut<MirrorTimerFeed>) {
let Some(mut script) = script else {
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
feed.0.clear();
return;
};
for edge in feed.0.drain(..) {
let raw = match edge {
MirrorTimerEdge::Start(start) => start.kind,
MirrorTimerEdge::Pause { kind, .. } | MirrorTimerEdge::Stop { kind } => kind,
};
let Some(kind) = MirrorTimerKind::from_wire(raw) else {
continue;
};
let name = ScriptValue::Str(script_name(kind).into());
let (event, args): (&str, Vec<ScriptValue>) = match edge {
MirrorTimerEdge::Start(start) => (
"MIRROR_TIMER_START",
vec![
name,
ScriptValue::Int(i64::from(start.remaining_ms)),
ScriptValue::Int(i64::from(start.duration_ms)),
ScriptValue::Int(i64::from(start.scale)),
ScriptValue::Int(i64::from(start.paused)),
ScriptValue::Str(caption(kind).into()),
],
),
MirrorTimerEdge::Pause { paused, .. } => (
"MIRROR_TIMER_PAUSE",
vec![name, ScriptValue::Int(i64::from(paused))],
),
MirrorTimerEdge::Stop { .. } => ("MIRROR_TIMER_STOP", vec![name]),
};
script.fire_event(event, args);
}
}
/// The mirror-timer UI seam: the queue + its drain, ordered like the cast bar's — before the VM
/// ticks, so an edge and its first OnUpdate land on the same frame.
pub(crate) struct UiMirrorPlugin;
impl Plugin for UiMirrorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MirrorTimerFeed>()
.add_systems(Update, feed_mirror_timers.in_set(UnitFeed).before(UiInput));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The client's arg1 names, in the server's `MirrorTimer::Type` order — and the fact that
/// type 0 is `EXHAUSTION` on the client but `FATIGUE` on the server. Getting this wrong is
/// silent: `MirrorTimerColors[timer]` would be nil and the bar's colour read would error.
#[test]
fn arg1_is_the_clients_name_not_the_servers() {
assert_eq!(script_name(MirrorTimerKind::Fatigue), "EXHAUSTION");
assert_eq!(script_name(MirrorTimerKind::Breath), "BREATH");
assert_eq!(script_name(MirrorTimerKind::FeignDeath), "FEIGNDEATH");
}
/// The captions are the 1.12 `GlobalStrings.lua` values the `%s_LABEL` lookup resolves to —
/// and the feign-death one is empty because that global does not exist.
#[test]
fn captions_are_the_globalstrings_values() {
assert_eq!(caption(MirrorTimerKind::Fatigue), "Fatigue");
assert_eq!(caption(MirrorTimerKind::Breath), "Breath");
assert_eq!(caption(MirrorTimerKind::FeignDeath), "");
}
}
@@ -0,0 +1,211 @@
//! The combo-point dots (decisions 0869, 0875) — the reference `ComboFrame` transcription driven
//! through the real loader: the show/hide edges, the per-point highlight/shine fade chain, the
//! "only newly-earned points flare" rule that `COMBO_FRAME_LAST_NUM_POINTS` exists to enforce, and
//! the two gates that live in `GetComboPoints` rather than in this Lua — rogue-or-druid only, and
//! the points must be banked on the CURRENT target.
use benilla_ui::script::{PlayerReqState, UiScript, UnitState};
/// The unit the points get banked on in these tests, and a second one to re-target to.
const MOB_A: u64 = 0xF130_0000_0000_0001;
const MOB_B: u64 = 0xF130_0000_0000_0002;
/// Load one shipped `assets/ui/<file>` (the panel tests' loader), panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`,
/// `UnitFrames.xml` for the `BenillaTargetFrame` the dots anchor to.
fn load_combo_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "ComboFrame.xml");
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// Put the player in a class that can see combo points, with `guid` selected — the state
/// `GetComboPoints` demands before it will report anything at all.
fn play_as(s: &mut UiScript, class_id: u32, target: u64) {
s.set_player_req_state(PlayerReqState {
class_id,
..Default::default()
});
s.set_unit(
"target",
Some(UnitState {
exists: true,
guid: target,
..Default::default()
}),
);
}
/// Run every queued fade past its longest leg (0.4 s highlight → 0.3 s shine in → 0.4 s shine out).
fn settle(s: &mut UiScript) {
s.eval::<()>("for i = 1, 40 do UIFrameFadeUpdate(0.05) end")
.unwrap();
}
fn shown(s: &mut UiScript) -> bool {
s.eval("return BenillaComboFrame:IsShown()").unwrap()
}
fn highlight_alpha(s: &mut UiScript, i: u32) -> f64 {
s.eval(&format!("return BenillaComboPoint{i}Highlight:GetAlpha()"))
.unwrap()
}
/// Zero points hides the frame; the first point shows it and lights exactly one dot; the count
/// rising lights the rest; the drop back to zero hides it again. The alpha readings run the fade to
/// completion first, since the highlight arrives via `UIFrameFade`, not a straight `SetAlpha`.
#[test]
fn combo_frame_follows_the_point_count() {
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A); // rogue, mob A selected
// Nothing banked: hidden, and the event on zero is a no-op that keeps it hidden.
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(!shown(&mut s), "no points ⇒ hidden");
// One point — a rogue's first builder.
s.set_combo_points(1, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(shown(&mut s), "one point ⇒ shown");
settle(&mut s);
let lit = highlight_alpha(&mut s, 1);
assert!(lit > 0.99, "point 1 lit, got alpha {lit}");
assert_eq!(
highlight_alpha(&mut s, 2),
0.0,
"point 2 stays dark at one combo point"
);
// Up to five: every dot lit, none left behind.
s.set_combo_points(5, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
settle(&mut s);
let all_lit: bool = s
.eval(
r#"
for i = 1, MAX_COMBO_POINTS do
if getglobal("BenillaComboPoint" .. i .. "Highlight"):GetAlpha() < 0.99 then
return false
end
end
return true
"#,
)
.unwrap();
assert!(all_lit, "all five dots lit at five points");
// The spend/expiry edge: back to zero hides the frame. This is the one the wire depends on —
// the server clears the byte on its own, so the falling edge is the only thing that ever takes
// the dots down.
s.set_combo_points(0, 0);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(!shown(&mut s), "zero points ⇒ hidden again");
}
/// A repaint at an UNCHANGED count must not re-flare the dots that were already lit — the whole
/// job of `COMBO_FRAME_LAST_NUM_POINTS`. `PLAYER_TARGET_CHANGED` is registered alongside the
/// combo event, so re-targeting mid-window fires exactly this repaint.
#[test]
fn a_repaint_at_the_same_count_does_not_re_flare() {
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A);
s.set_combo_points(2, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
settle(&mut s);
// Both settled: highlights up, shines burned out.
let shine: f64 = s.eval("return BenillaComboPoint2Shine:GetAlpha()").unwrap();
assert_eq!(shine, 0.0, "the shine fades back out");
// A re-select of the SAME unit at the same count: no new fade is queued for either dot.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let fading: bool = s
.eval("return UIFrameIsFading(BenillaComboPoint1Highlight) ~= nil")
.unwrap();
assert!(!fading, "an unchanged count re-flares nothing");
assert!(
highlight_alpha(&mut s, 2) > 0.99,
"and leaves the lit dots lit"
);
}
/// **A warrior sees nothing** — `GetComboPoints 0x51a190`'s class gate (`cmp al,4 / cmp al,0xb`).
/// The server really does bank a point for them on a victim's dodge, and that byte really does
/// reach us: it is what greys Overpower through the usable walk's leg 5. It just never reaches the
/// UI. Every class outside {rogue, druid} takes the same path; the wire state here is identical to
/// the rogue ladder's first rung, so only the class differs.
#[test]
fn a_warriors_overpower_point_lights_no_dot() {
let mut s = load_combo_frame();
play_as(&mut s, 1, MOB_A); // warrior
s.set_combo_points(1, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(!shown(&mut s), "a warrior's banked point shows no dots");
let points: i64 = s.eval("return GetComboPoints()").unwrap();
assert_eq!(points, 0, "and the binding itself reports zero");
// A druid is on the other side of the same gate (class 11 — cat form's builders).
play_as(&mut s, 11, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(shown(&mut s), "a druid sees the same banked point");
}
/// **Combo points are per target** — `0x51a190` compares `PLAYER_FIELD_COMBO_TARGET` against the
/// current-target GUID global and pushes 0 on a mismatch. So re-targeting empties the dots without
/// the server touching the count, and selecting the banked unit again refills them. Losing the
/// target entirely (guid 0) empties them too.
#[test]
fn re_targeting_empties_the_dots_without_the_count_moving() {
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A);
s.set_combo_points(3, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
settle(&mut s);
assert!(shown(&mut s), "three points on the selected mob ⇒ shown");
// Select a different mob. The wire count is untouched — only the reader's answer changes.
play_as(&mut s, 4, MOB_B);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!shown(&mut s), "points banked elsewhere ⇒ no dots");
let points: i64 = s.eval("return GetComboPoints()").unwrap();
assert_eq!(points, 0, "the binding hides them, the wire still has them");
// Select the banked mob again: all three come straight back.
play_as(&mut s, 4, MOB_A);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
settle(&mut s);
assert!(shown(&mut s), "re-selecting the banked mob refills them");
assert!(highlight_alpha(&mut s, 3) > 0.99, "all three, not just one");
// And no target at all is a mismatch like any other.
s.set_unit("target", None);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!shown(&mut s), "no target ⇒ no dots");
}
@@ -0,0 +1,337 @@
//! The breath / fatigue bars against the transcribed reference behavior (decision 0874): the
//! extracted 1.12 `MirrorTimer.lua` is the spec — a bar per running timer, the timer's own colour
//! and caption, the value integrating `scale * elapsed` between packets, and the frame released
//! back to the pool on STOP.
//!
//! Two halves, and the second is the load-bearing one (the cast bar's lesson, `cast_tests`): the
//! state tests read the Lua back, which is blind to what actually *paints*. The
//! [`draw list`](UiScript::extract) tests below are the ones that would catch a bar that is shown,
//! correctly valued, and invisible.
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MirrorTimer.xml");
s
}
/// The app's own `MIRROR_TIMER_START` arg list (`ui_mirror::feed_mirror_timers`): the client's
/// timer name, milliseconds, milliseconds, the signed rate, the paused flag, the caption.
fn start(
s: &mut UiScript,
name: &str,
remaining_ms: i64,
duration_ms: i64,
scale: i64,
label: &str,
) {
s.fire_event(
"MIRROR_TIMER_START",
vec![
ScriptValue::Str(name.into()),
ScriptValue::Int(remaining_ms),
ScriptValue::Int(duration_ms),
ScriptValue::Int(scale),
ScriptValue::Int(0),
ScriptValue::Str(label.into()),
],
);
}
fn shown(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsShown()"))
.unwrap()
}
fn bar_value(s: &UiScript, frame: &str) -> f64 {
s.eval::<f64>(&format!("return {frame}StatusBar:GetValue()"))
.unwrap()
}
fn bar_max(s: &UiScript, frame: &str) -> f64 {
s.eval::<f64>(&format!(
"local lo, hi = {frame}StatusBar:GetMinMaxValues(); return hi"
))
.unwrap()
}
fn bar_color(s: &UiScript, frame: &str) -> (f64, f64, f64) {
s.eval::<(f64, f64, f64)>(&format!(
"local r, g, b = {frame}StatusBar:GetStatusBarColor(); return r, g, b"
))
.unwrap()
}
fn caption(s: &UiScript, frame: &str) -> String {
s.eval::<String>(&format!("return {frame}Text:GetText()"))
.unwrap()
}
/// One tick of the app's real order (`drive_script`): OnUpdate, resolve, then the draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
s.extract()
}
/// The quad drawn from `Interface\...\<leaf>`, if any.
fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQuad> {
quads.iter().find(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => p.ends_with(leaf),
_ => false,
})
}
/// Every bar starts hidden — no timer, no chrome on screen.
#[test]
fn the_three_timers_load_hidden() {
let mut s = harness();
for i in 1..=3 {
assert!(!shown(&s, &format!("MirrorTimer{i}")), "MirrorTimer{i}");
}
assert!(
tex_quad(&frame(&mut s, 0.016), "UI-CastingBar-Border").is_none(),
"no border chrome before a timer starts"
);
}
/// A breath timer: the first free frame takes it, blue, captioned, scaled to the full span, and
/// seated at the value the server named.
#[test]
fn breath_takes_the_first_bar_in_the_reference_blue() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
assert!(shown(&s, "MirrorTimer1"));
assert!(!shown(&s, "MirrorTimer2"), "only one timer is running");
assert_eq!(caption(&s, "MirrorTimer1"), "Breath");
// Milliseconds on the wire, seconds in the bar — the reference's `/1000` on both.
assert_eq!(bar_value(&s, "MirrorTimer1"), 45.0);
assert_eq!(bar_max(&s, "MirrorTimer1"), 60.0);
// MirrorTimerColors["BREATH"]
let (r, g, b) = bar_color(&s, "MirrorTimer1");
assert!(
(r - 0.0).abs() < 1e-6 && (g - 0.5).abs() < 1e-6 && (b - 1.0).abs() < 1e-6,
"reference blue, got ({r}, {g}, {b})"
);
}
/// Fatigue is a different timer type, a different colour and a different caption — and the
/// client's key for it is `EXHAUSTION`, not the server's `FATIGUE`.
#[test]
fn fatigue_is_the_reference_yellow_under_its_own_caption() {
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
assert!(shown(&s, "MirrorTimer1"));
assert_eq!(caption(&s, "MirrorTimer1"), "Fatigue");
let (r, g, b) = bar_color(&s, "MirrorTimer1");
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.9).abs() < 1e-6 && (b - 0.0).abs() < 1e-6,
"reference yellow, got ({r}, {g}, {b})"
);
}
/// The whole client-side motion: the frame integrates `scale * elapsed` per OnUpdate. At the
/// reference's draining rate that is one bar-second per real second.
#[test]
fn the_bar_drains_at_the_servers_signed_rate() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
for _ in 0..100 {
frame(&mut s, 0.05); // 5 s of client time, in reference-sized ticks
}
let after = bar_value(&s, "MirrorTimer1");
assert!(
(after - 40.0).abs() < 0.05,
"5 s at scale -1 should land near 40, got {after}"
);
// Surfacing: the server re-sends the same timer with the +10 refill rate (there is no update
// opcode). The bar must reverse, ten times faster than it drained.
start(&mut s, "BREATH", 40_000, 60_000, 10, "Breath");
for _ in 0..20 {
frame(&mut s, 0.05); // 1 s
}
let refilled = bar_value(&s, "MirrorTimer1");
assert!(
(refilled - 50.0).abs() < 0.5,
"1 s at scale +10 should climb ~10 bar-seconds to ~50, got {refilled}"
);
}
/// A paused timer holds its value — the frozen state the server sends when it stops the clock.
#[test]
fn a_paused_timer_holds_its_value() {
let mut s = harness();
s.fire_event(
"MIRROR_TIMER_START",
vec![
ScriptValue::Str("BREATH".into()),
ScriptValue::Int(45_000),
ScriptValue::Int(60_000),
ScriptValue::Int(-1),
ScriptValue::Int(1), // paused
ScriptValue::Str("Breath".into()),
],
);
for _ in 0..40 {
frame(&mut s, 0.05);
}
assert_eq!(
bar_value(&s, "MirrorTimer1"),
45.0,
"frozen: no integration at all"
);
// MIRROR_TIMER_PAUSE(name, 0) releases it (our arg2 reading — the reference's own branch is
// bugged and the packet is never sent by vmangos; see MirrorTimer.xml departure 3).
s.fire_event(
"MIRROR_TIMER_PAUSE",
vec![ScriptValue::Str("BREATH".into()), ScriptValue::Int(0)],
);
for _ in 0..20 {
frame(&mut s, 0.05);
}
assert!(
bar_value(&s, "MirrorTimer1") < 44.5,
"unfrozen: draining again"
);
}
/// Two timers at once (deep water: fatigue *and* breath) stack onto separate frames, each keeping
/// its own colour — and a re-STATE of one lands back on the frame already holding it rather than
/// consuming a third.
#[test]
fn two_timers_stack_and_restate_in_place() {
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
assert!(shown(&s, "MirrorTimer1") && shown(&s, "MirrorTimer2"));
assert!(!shown(&s, "MirrorTimer3"));
assert_eq!(caption(&s, "MirrorTimer1"), "Fatigue");
assert_eq!(caption(&s, "MirrorTimer2"), "Breath");
// The server re-sends breath on every change; it must reuse frame 2, not take frame 3.
start(&mut s, "BREATH", 30_000, 60_000, -1, "Breath");
assert!(!shown(&s, "MirrorTimer3"), "re-state must not take a frame");
assert_eq!(bar_value(&s, "MirrorTimer2"), 30.0);
assert_eq!(bar_value(&s, "MirrorTimer1"), 60.0, "fatigue untouched");
}
/// STOP hides only the named timer and hands its frame back to the pool.
#[test]
fn stop_hides_that_timer_and_frees_its_frame() {
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
s.fire_event(
"MIRROR_TIMER_STOP",
vec![ScriptValue::Str("EXHAUSTION".into())],
);
assert!(!shown(&s, "MirrorTimer1"), "fatigue's frame released");
assert!(shown(&s, "MirrorTimer2"), "breath untouched");
// The freed frame is reusable: a fresh timer takes it back.
start(&mut s, "FEIGNDEATH", 10_000, 10_000, -1, "");
assert!(shown(&s, "MirrorTimer1"));
assert_eq!(
caption(&s, "MirrorTimer1"),
"",
"no FEIGNDEATH_LABEL exists in the 1.12 GlobalStrings"
);
}
/// Entering the world clears every bar — a worldport must not leave a stale breath bar behind
/// (the reference's `PLAYER_ENTERING_WORLD` arm).
#[test]
fn entering_the_world_clears_every_bar() {
let mut s = harness();
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
for i in 1..=3 {
assert!(!shown(&s, &format!("MirrorTimer{i}")), "MirrorTimer{i}");
}
}
/// A STOP for a timer that is not running must be inert — not an error, and not a frame hidden
/// out from under a different timer.
#[test]
fn a_stop_for_an_idle_timer_touches_nothing() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
s.fire_event(
"MIRROR_TIMER_STOP",
vec![ScriptValue::Str("EXHAUSTION".into())],
);
assert!(shown(&s, "MirrorTimer1"), "breath's bar survives");
assert_eq!(bar_value(&s, "MirrorTimer1"), 45.0);
}
/// The paint half. A running timer must actually put its chrome and its fill on screen — the
/// state tests above all pass on a bar that draws nothing.
#[test]
fn a_running_timer_paints_its_chrome_and_fill() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
let quads = frame(&mut s, 0.016);
let border = tex_quad(&quads, "UI-CastingBar-Border").expect("the bar's border chrome");
let border_w = border.rect.expect("the border resolves a rect").width();
assert!(border_w > 100.0, "the 256-wide border, got {border_w}");
let fill = tex_quad(&quads, "UI-StatusBar").expect("the status-bar fill");
let fill_w = fill.rect.expect("the fill resolves a rect").width();
// 45 of 60 seconds over the reference's 195 px bar.
let expected = 195.0 * 45.0 / 60.0;
assert!(
(fill_w - expected).abs() < 2.0,
"fill should be ~{expected} px at 45/60, got {fill_w}"
);
assert!(
quads.iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Breath"
)),
"the caption is drawn"
);
}
/// …and a stopped timer takes all of it off screen again.
#[test]
fn a_stopped_timer_paints_nothing() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
frame(&mut s, 0.016);
s.fire_event("MIRROR_TIMER_STOP", vec![ScriptValue::Str("BREATH".into())]);
let quads = frame(&mut s, 0.016);
assert!(tex_quad(&quads, "UI-CastingBar-Border").is_none());
assert!(tex_quad(&quads, "UI-StatusBar").is_none());
}
+118 -2
View File
@@ -216,11 +216,14 @@ fn setup_script(world: &mut World) {
}
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
// The unit frames are the default player UI — always loaded in a normal run. Captures stay
// pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in.
let capturing = world.contains_resource::<crate::capture::CaptureMode>();
if !capturing || std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
load_default_ui(&script);
// Errors are already logged per file as they happen; the returned list is the test-side
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_default_ui(&script);
}
world.insert_non_send_resource(script);
}
@@ -263,12 +266,86 @@ fn load_global_strings(world: &mut World, script: &UiScript) {
}
}
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
/// take that file's **data** and none of its code — only whole lines matching
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
fn load_emote_tokens(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<crate::assets::WorldAssets>() else {
return; // already WARNed by load_global_strings
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
return;
}
};
let table: Vec<&str> = src
.lines()
.map(str::trim)
.filter(|l| is_emote_token_line(l))
.collect();
let count = table.len();
if let Err(e) = script.run(&table.join("\n")) {
error!("ui_script: emote token table failed to run: {e}");
return;
}
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
match sentinel.as_deref() {
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
other => error!(
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
emote slash commands are broken"
),
}
}
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
/// lines equivalent to reading data (no calls, no expressions, no side effects).
pub(crate) fn is_emote_token_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("EMOTE") else {
return false;
};
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
return false;
};
let Some(name) = rest.strip_suffix("\";") else {
return false;
};
digits > 0
&& !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
/// Load benilla's own default UI (`assets/ui/*.xml` — the unit frames and the action bar) through
/// the engine-free loader, resolving any `<Include>`/`<Script file=>` references against the
/// crate's `assets/ui` dir. This is our content (MIT/Apache), committed and read from source —
/// `CARGO_MANIFEST_DIR` points at the `benilla` crate. Textures (`Interface\…`) still resolve at
/// render through the MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
fn load_default_ui(script: &UiScript) {
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. The manifest is an
/// inline array no other test walks, so before that assertion a broken entry — a bad file name, a
/// frame that collides with a later window's, a template referenced before its definer — reached a
/// real run with nothing but a log line. Capture runs cannot cover it either: they skip this
/// function entirely unless `WOW_CAPTURE_UI=1`.
fn load_default_ui(script: &UiScript) -> Vec<String> {
let mut failures = Vec::new();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative), resolved against our own assets/ui dir.
@@ -311,6 +388,9 @@ fn load_default_ui(script: &UiScript) {
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
"ComboFrame.xml",
// The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
// transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
// only Fonts.xml (CombatTextFont) + UIParent before it.
@@ -364,6 +444,10 @@ fn load_default_ui(script: &UiScript) {
// The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
// the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
"CastingBar.xml",
// The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
// MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
// before it; kept beside the cast bar, whose border art it shares.
"MirrorTimer.xml",
// The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
// aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
// (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
@@ -509,6 +593,7 @@ fn load_default_ui(script: &UiScript) {
}
for e in &report.errors {
error!("ui_script({file}): {e}");
failures.push(format!("{file}: {e}"));
}
info!(
"ui_script: {file} loaded ({} frames materialized)",
@@ -522,7 +607,9 @@ fn load_default_ui(script: &UiScript) {
// application; the stance bar's show/hide handles the rest at runtime.
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
failures.push(format!("managed-positions bootstrap: {e}"));
}
failures
}
/// Feed synthetic `"player"`/`"target"` snapshots each frame (overriding the real feed, which finds
@@ -530,6 +617,10 @@ fn load_default_ui(script: &UiScript) {
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
/// unit frames (the target one included, so captures regression-test its show-on-target path).
fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>) {
/// The synthetic target's guid — a creature-family high part, so nothing mistakes it for a
/// player. Only its *distinctness* matters.
const DEMO_TARGET_GUID: u64 = 0xF130_0000_0000_0001;
let Some(mut script) = script else {
return;
};
@@ -584,9 +675,28 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>)
// A beast type word so the mouseover/target tooltip's level line reads
// "Level 3 Beast" in captures.
creature_type_name: Some("Beast".into()),
// A real guid: the combo seed below banks its points against exactly this unit, and
// `GetComboPoints` refuses to report points banked on anything but the CURRENT target
// (decision 0875). Also stops the demo player and target reading as the same unit,
// which two default zeros would.
guid: DEMO_TARGET_GUID,
..Default::default()
}),
);
// The combo dots (`ComboFrame`) need a class that can SEE them and points banked on the
// selected unit — seeded only for their own scenario, because the demo player is a warrior
// everywhere else (ui-char's level line reads "Level 12 Night Elf Warrior" off that) and a
// warrior authentically lights no dot. Run it with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-combopoints`.
if std::env::var("WOW_CAPTURE").as_deref() == Ok("ui-combopoints") {
script.set_player_req_state(benilla_ui::script::PlayerReqState {
level: 12,
class_id: 4, // rogue
..Default::default()
});
script.set_combo_points(4, DEMO_TARGET_GUID);
script.fire_event("PLAYER_COMBO_POINTS", vec![]);
}
if !*fired {
// A few synthetic bar slots so captures show the action bar populated (battle-stance
// page: actions 73.. — the page a real warrior login lands on). Spread across the 12 wells
@@ -691,9 +801,15 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<bool>)
#[cfg(test)]
mod cast_tests;
#[cfg(test)]
mod mirror_timer_tests;
#[cfg(test)]
mod combat_text_tests;
#[cfg(test)]
mod combo_frame_tests;
#[cfg(test)]
mod unit_frame_tests;
@@ -34,3 +34,22 @@ fn every_shipped_ui_xml_parses() {
"only {checked} xml files swept — sweep broke"
);
}
/// The WHOLE manifest LOADS, in its real order, with zero loader errors — the check the parse
/// sweep above structurally cannot give, and the one the app itself never made.
///
/// [`super::load_default_ui`] only ever *logged* its errors, so a broken manifest entry reached a
/// real run behind a log line nobody greps: a mistyped file name, a frame name colliding with a
/// later window's, a template used before its definer, an `<Include>` that resolves to nothing.
/// Nor could a capture run have caught it — captures skip the manifest entirely unless
/// `WOW_CAPTURE_UI=1`. This is that assertion, over the array the app really walks, so a new
/// window's entry is covered the moment it is added rather than when someone remembers to test it.
#[test]
fn the_whole_shipped_manifest_loads_without_errors() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+75
View File
@@ -97,6 +97,10 @@ struct UnitFeedState {
/// 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 `(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").
@@ -725,12 +729,83 @@ fn feed_units(
script.fire_event("PLAYER_XP_UPDATE", vec![]);
}
}
// 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(feed.last_combo, banked) {
feed.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 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"
);
}
/// 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.