diff --git a/crates/benilla-formats/src/emotes.rs b/crates/benilla-formats/src/emotes.rs index 9e0ec53c..5b046cff 100644 --- a/crates/benilla-formats/src/emotes.rs +++ b/crates/benilla-formats/src/emotes.rs @@ -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, + /// `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, } 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 { + (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 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 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 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() diff --git a/crates/benilla-formats/src/sheathe.rs b/crates/benilla-formats/src/sheathe.rs index 46f65090..9a297068 100644 --- a/crates/benilla-formats/src/sheathe.rs +++ b/crates/benilla-formats/src/sheathe.rs @@ -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 { [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)); + } + } } diff --git a/crates/benilla-formats/src/skill_lines.rs b/crates/benilla-formats/src/skill_lines.rs index 7e62494e..f7c2c9ae 100644 --- a/crates/benilla-formats/src/skill_lines.rs +++ b/crates/benilla-formats/src/skill_lines.rs @@ -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>, + /// 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, } 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 { + 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, + ) -> Option { + 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 { 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 { 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 = [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 = [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 = [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 = [6603].into_iter().collect(); + assert_eq!(cat.rank_successor(6603), None); + assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603)); + } } diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 81e2c64f..8fbb9e5e 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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`]); diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index bd09a7d3..c21b991e 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -445,6 +445,11 @@ pub fn decode(packet: ServerPacket) -> Vec { 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)], diff --git a/crates/benilla-protocol/src/messages/mirror_timer.rs b/crates/benilla-protocol/src/messages/mirror_timer.rs new file mode 100644 index 00000000..62093a0a --- /dev/null +++ b/crates/benilla-protocol/src/messages/mirror_timer.rs @@ -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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index c4d0946a..9e85c8c4 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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}; diff --git a/crates/benilla-protocol/src/messages/movement.rs b/crates/benilla-protocol/src/messages/movement.rs index 3afeff5e..a6b7ca6a 100644 --- a/crates/benilla-protocol/src/messages/movement.rs +++ b/crates/benilla-protocol/src/messages/movement.rs @@ -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) | diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 4b097d48..9f1d5de4 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 6effa028..f532c753 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index b3a288cb..d224e915 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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 { 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 { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index e61caaab..a775b6b9 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 7bb3a37a..69c631ed 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -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 { 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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 0a3bd4c2..790ce02c 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -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 diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5d9a207e..e4f4fcff 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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], diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 2777f1f6..529eaea4 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -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::().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 diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 666672db..8dadbb9e 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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::().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 diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 86277df8..111f3e20 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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 { diff --git a/crates/benilla/assets/ui/CombatText.xml b/crates/benilla/assets/ui/CombatText.xml index 1942c724..1c47d52e 100644 --- a/crates/benilla/assets/ui/CombatText.xml +++ b/crates/benilla/assets/ui/CombatText.xml @@ -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 diff --git a/crates/benilla/assets/ui/ComboFrame.xml b/crates/benilla/assets/ui/ComboFrame.xml new file mode 100644 index 00000000..cebe32a8 --- /dev/null +++ b/crates/benilla/assets/ui/ComboFrame.xml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("PLAYER_TARGET_CHANGED"); + this:RegisterEvent("PLAYER_COMBO_POINTS"); + BenillaComboPoint1Highlight:SetAlpha(0); + BenillaComboPoint1Shine:SetAlpha(0); + + + BenillaComboPointsFrame_OnEvent(); + + + + + + diff --git a/crates/benilla/assets/ui/MirrorTimer.xml b/crates/benilla/assets/ui/MirrorTimer.xml new file mode 100644 index 00000000..ba009ae9 --- /dev/null +++ b/crates/benilla/assets/ui/MirrorTimer.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:SetFrameLevel(this:GetFrameLevel() - 1) + + + + + + MirrorTimerFrame_OnLoad() + MirrorTimerFrame_OnEvent() + MirrorTimerFrame_OnUpdate(this, arg1) + + + + + + + + + + + + + + + + + + + + + + + + + MirrorTimerDriver_OnLoad() + MirrorTimerDriver_OnEvent() + + + diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index ab228e1a..5201011a 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -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!( diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index 81ac333d..d75b4e07 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -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 diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index 8eb8f9a3..209c130b 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -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, + /// 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, /// 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, + /// 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, diff --git a/crates/benilla/src/creature_anim/driver.rs b/crates/benilla/src/creature_anim/driver.rs index 3053e951..c33b9684 100644 --- a/crates/benilla/src/creature_anim/driver.rs +++ b/crates/benilla/src/creature_anim/driver.rs @@ -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>, // 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>, + // 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