diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index db90cd34..02b03854 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -438,12 +438,58 @@ fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) { /// as the reference divides damage by it. The same stand-in /// [`UnitCombatStats::default`](benilla_ui::script::UnitCombatStats::default) uses before this /// feed has pushed at all, so the two answers agree across the push. +/// +/// **The substitution is invisible once it is made**, which is what +/// [`warn_once_if_main_swing_substituted`] exists to fix: a sheet carrying 2000 reads exactly like +/// a sheet for a genuine 2.00 s weapon, and the addons that divide by it cannot tell the two +/// apart either. fn base_swing(streamed: Option) -> u32 { streamed .filter(|&ms| ms != 0) .unwrap_or(benilla_ui::script::VANILLA_SWING_MS) } +/// Whether the player's main-hand swing has already been reported as substituted this process. +static MAIN_SWING_SUBSTITUTED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Say once, on the host channel, that the player's `UNIT_FIELD_BASEATTACKTIME` was absent or zero +/// and [`base_swing`] substituted the vanilla 2000. +/// +/// This is the one fact no reader downstream can recover. `UnitAttackSpeed("player")` answers a +/// number either way, `char_stats`' own stand-in warning fires only when *no sheet at all* has +/// been pushed, and 2.00 s is a real weapon speed — so a substituted sheet and a genuine 2.00 s +/// weapon are indistinguishable from Lua, from the log, and from every test that seats a sheet +/// before asking. +/// +/// It matters because the value is latched, not polled. SP_SwingTimer caches +/// `UnitAttackSpeed("player")` into `st_timerMax`, and SuperCleveRoidMacros computes +/// `[noslamclip]`'s whole window from that cache as `(st_timerMax - slamCastTime) / st_timerMax`. +/// On this realm Slam is a **2.5 s** cast, so a substituted 2.0 makes `maxSlamStart` negative and +/// `GetSlamWindowPercent()` returns a hard `0` — the conditional is then false for every press of +/// the session, with nothing raised and nothing in the log. A real 2.70 s weapon would have given +/// a 7.4% window instead. Those two outcomes look identical in play until this line exists. +fn warn_once_if_main_swing_substituted(store: &ObjectStore) { + if store + .0 + .unit_base_attack_time(0) + .filter(|&ms| ms != 0) + .is_some() + || MAIN_SWING_SUBSTITUTED.swap(true, std::sync::atomic::Ordering::Relaxed) + { + return; + } + warn!( + "char: the player's UNIT_FIELD_BASEATTACKTIME (field {}) is absent or zero, so the sheet \ + carries the vanilla {} ms stand-in. Every addon that divides by a swing speed is now \ + working from it — SP_SwingTimer latches it into `st_timerMax` and `[noslamclip]` reads \ + a zero-width window off that. Compare it against the real interval between your own \ + `You hit` lines in Logs/WoWCombatLog.txt: if they disagree, this is why.", + benilla_protocol::field::FIELD_UNIT_BASEATTACKTIME, + benilla_ui::script::VANILLA_SWING_MS, + ); +} + pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats { let f = &store.0; @@ -1178,6 +1224,7 @@ pub(crate) fn feed_char( return; } + warn_once_if_main_swing_substituted(store); let stats = combat_stats(store, objects, &items, &commands); if memo.last_stats.as_ref() != Some(&stats) { gate.audit("feed_char", "the combat-stats snapshot");