GetCastTime applies spell-mod op 0xa itself — so a talented Slam is 2.0 s, not 2.5

grom's probe closed the slam hunt in one line:

    mx=2.7  st=1.0255  u=2.7  c=2.5  w=7.4074

Both of my remaining candidates were wrong. The swing speed arrived correctly
(2.7, not the 2000 ms stand-in the last commit instrumented) and the swing timer
was running, so neither the feed nor the combat events were at fault. That leaves
one term: `c`, the cast time, and `w = (2.70 - 2.50) / 2.70` falls straight out
of it.

`SuperCleveRoidMacros` does not ask a verb for that number. `GetSlamCastTime()`
walks the spellbook for Slam, builds a nil-parent `GameTooltipTemplate` scanning
tooltip, calls `SetSpell`, and reads the `"%d+%.?%d* sec cast"` line — so the
whole conditional rests on one line of our spellbook tooltip.

`Spell_C::GetCastTime` (`0x6e3340`) applies `SPELLMOD_CASTING_TIME` **inside
itself**, after the row's minimum clamp:

    6e33b0:  cmp    %esi,%eax         ; row minimum (2500 for Slam)
    6e33b4:  mov    %esi,-0x4(%ebp)   ; ...clamped to it
    6e33b7:  push   $0xa              ; op 10 = SPELLMOD_CASTING_TIME
    6e33be:  call   0x6e6af0          ; value = (value + flat) * pct / 100

and the tooltip's cast line is a call to it, not a DBC read (`52eb48`/`52eb4b`/
`52eb4d: call 0x6e3340`). We read `SpellCastTimes.dbc` directly, so a
talent-shortened cast showed its base length — a deviation `ui_action/mod.rs` and
`cast_times.rs` both *documented* without anyone pricing it.

The realm's own data prices it. Slam (1464/8820/11604/11605) is `CastingTimeIndex`
20, row `{base 2500, perLevel 0, minimum 2500}`. Improved Slam is two ranks —
12862 and 12330, aura 107 / misc 10 / `basePoints` -251 and -501 with `dieSides`
1, a flat -250 or -500 ms on op 10 against `SpellFamilyFlags` bit 21, family 4
(warrior, `ChrClasses.dbc` field 15; the five-rank version is `zzOLD`). So a 2/2
warrior's Slam is 2.0 s, and on his 2.70 s main hand:

    2.5 s (ours)        0.20 s window      7.4% of the swing
    2.0 s (his client)  0.70 s window     25.9% of the swing

It is a size, not a sign. 0.20 s is narrower than human reaction time, so a
player pressing by hand overshoots it every press; 0.70 s he lands in every
swing. "477 Heroic Strikes, zero Slams" and "works just fine on the normal
client" are the same observation.

Note the order: the minimum clamp is 2500 and it runs *before* the modifier.
Applying op 0xa first would pin Slam at 2.5 s for ever and look identical to the
bug it was meant to fix.

`Spells::cast_time_ms` is now `GetCastTime` — row resolve, minimum clamp, op 0xa
— with `cast_time_signed_ms` for the reference's `allowNegative` form the
tooltip's "Instant cast" ladder needs (`0x52ebce`), and `cast_time_row_ms` kept
for the one question that wants the row alone: whether a spell has a cast time
*authored* at all, which is `opens_cast_bar`'s gate. The tooltip's cast line and
the four live readers pass the modifier tables through — including both
`PendingCast` arms, so nampower's local lockout on a talented cast was 500 ms too
long as well. The tooltip feed re-pushes on a level change now that the cast line
is level-scaled.

Still deviating, written where it lives: the final `UNIT_MOD_CAST_SPEED`
multiplier (`6e342c`, taken only when `Attributes & 0x30` is clear) is not
applied, because no site streams unit field 139. It is 1.0 without a casting-speed
aura, so it moves nothing a physical ability does.

Tests: `a_talent_shortened_cast_line_shows_its_talented_length` grades Slam on the
real DBCs untalented, at 2/2, and against a foreign class family (the gate that
stops a mage's table shortening a warrior's Slam). The harness test now drives
grom's own addon chain through both widths — `WINDOW_PCT` 7.4074074074074 and
`WINDOW_PCT_TALENTED` 25.925925925926 — with the addon's own
`RefreshSlamCastTime` between them, because its scan is cached for 2 s and a test
that re-pushed without it would measure the stale value and pass.

Both earlier answers are written down in `WINDOW_PCT`'s doc and in
docs/ADDON_COMPAT.md: I called this "not ours" twice, from two green tests, and
each was green because it asserted a number I had supplied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-30 13:31:26 -06:00
co-authored by Claude Opus 5
parent 7c0a99c006
commit 08d574b09c
9 changed files with 330 additions and 34 deletions
@@ -24,22 +24,41 @@ use super::{
};
/// Plates a crowded pull shows; the nameplate CVar cap and `pfValidUnits`' own range.
/// `CleveRoids.GetSlamWindowPercent()` for grom's own numbers, as Lua prints it.
/// `CleveRoids.GetSlamWindowPercent()` for grom's own numbers, as Lua prints it: the window an
/// **untalented** warrior gets, which is what this client used to hand every warrior.
///
/// `(mainhand - slamCast) / mainhand * 100` = `(2.70 - 2.50) / 2.70 * 100`. **Both numbers are
/// the realm's, not vanilla's.** Slam on OctoWoW is a 2.5 s cast — `Spell.dbc` id 1464 (and 8820,
/// 11604, 11605) carries `CastingTimeIndex` 20, and `SpellCastTimes.dbc` row 20 is 2500 ms, where
/// vanilla's Slam is 1.5 s. SuperCleveRoidMacros' own `DEFAULT_SLAM_CAST = 2.5 -- Turtle WoW`
/// agrees independently.
/// `(mainhand - slamCast) / mainhand * 100` = `(2.70 - 2.50) / 2.70 * 100`. Both numbers are the
/// realm's, not vanilla's: Slam on OctoWoW is a 2.5 s cast — `Spell.dbc` 1464 (and 8820, 11604,
/// 11605) carries `CastingTimeIndex` 20, and `SpellCastTimes.dbc` row 20 is
/// `{base 2500, perLevel 0, minimum 2500}`, where vanilla's Slam is 1.5 s.
/// SuperCleveRoidMacros' own `DEFAULT_SLAM_CAST = 2.5 -- Turtle WoW` agrees independently.
///
/// So the no-clip window on a 2.70 s weapon is **0.2 s wide, 7.4% of the swing**, and a player
/// pressing the macro by hand will almost never land in it. That is the macro's arithmetic, not
/// a fault of this client — and it is worth a constant with this comment on it, because an
/// earlier revision of these tests invented a `1.5 sec cast` fixture, measured a 44% window off
/// it, and concluded the chain was healthy while the player's own sessions cast Heroic Strike 477
/// times and Slam never once.
/// **What took two wrong turns to find is that 2.5 is not the number a talented warrior's client
/// reports.** Improved Slam (`Spell.dbc` 12862 Rank 1, 12330 Rank 2) is aura 107 / misc 10 /
/// `basePoints` −251 and −501 with `dieSides` 1 — a flat −250 or −500 ms on spell-mod op `0xa`,
/// and `Spell_C::GetCastTime` applies op `0xa` *itself* (`6e33b7: push $0xa`), after the row's
/// minimum clamp and before returning to every caller, the spellbook tooltip's cast line included
/// (`52eb4d: call 0x6e3340`). This client read `SpellCastTimes.dbc` directly instead, so it
/// reported 2.5 s to a warrior whose real cast was 2.0 — see
/// [`crate::ui_action::Spells::cast_time_signed_ms`].
///
/// The consequence is the whole defect, and it is a size, not a sign: a 2.0 s cast on a 2.70 s
/// weapon leaves a **0.70 s** no-clip window ([`WINDOW_PCT_TALENTED`], 25.9% of the swing), a
/// 2.5 s cast leaves **0.20 s**. 0.20 s is narrower than human reaction time, so a player
/// pressing by hand overshoots it every single time, which is exactly the record: Heroic Strike
/// 477 times, Slam never once, on a macro that "works just fine on the normal client".
///
/// Both wrong turns are worth keeping written down, because each produced a green test:
/// one revision invented a `1.5 sec cast` fixture and measured a 44% window off a number nothing
/// shipped; the next corrected the fixture to the realm's 2.5 and concluded from it that the
/// arithmetic was the macro's own and not this client's fault. The fixture was right that time.
/// The conclusion was not — the feed behind it was wrong.
const WINDOW_PCT: &str = "7.4074074074074";
/// [`WINDOW_PCT`] with 2/2 Improved Slam applied — `(2.70 - 2.00) / 2.70 * 100`, the window the
/// reference client gives the same character on the same weapon.
const WINDOW_PCT_TALENTED: &str = "25.925925925926";
const PLATES: u32 = 40;
/// Frames per timed window.
@@ -1844,8 +1863,10 @@ fn seat_a_warrior_mid_fight(s: &mut UiScript, offhand: bool) {
/// * The `<OnUpdate>` delta reaching `SP_ST_OnUpdate(arg1)` as seconds.
///
/// **All five answer correctly** once the sheet is pushed — see [`WINDOW_PCT`] for the window
/// they produce and for the fixture mistake that made an earlier revision of this test read as a
/// clean bill of health while the macro was still picking Heroic Strike every time. It is the first bullet's edge: before the stats feed has pushed,
/// they produce, for the two fixture-and-conclusion mistakes that made earlier revisions of this
/// test read as a clean bill of health while the macro was still picking Heroic Strike every
/// time, and for the cast-time feed that was the real cause. It is the first bullet's edge:
/// before the stats feed has pushed,
/// `UnitAttackSpeed("player")` answered `0`, and SP_SwingTimer's `ADDON_LOADED` arm latches that
/// into `st_timerMax` behind `if not st_timerMax` — where `0` is truthy, so the guard never
/// re-fires. The first write to that global in a session is therefore always the pre-push answer.
@@ -2002,7 +2023,7 @@ fn the_slam_clip_conditional_answers_on_the_players_own_interface() {
ask(&mut s, "CleveRoids.GetSlamWindowPercent()"),
WINDOW_PCT,
"(2.70 - 2.50) / 2.70 — the share of the swing in which Slam still beats the \
auto-attack: 0.2 s of 2.7, which is the whole story of this macro on this weapon"
auto-attack: 0.2 s of 2.7, off the untalented cast time seeded above"
);
assert_eq!(
ask(&mut s, "CleveRoids.ValidateNoSlamClip()"),
@@ -2011,6 +2032,37 @@ fn the_slam_clip_conditional_answers_on_the_players_own_interface() {
first clause is the one that should fire"
);
// And the same chain on the cast time a 2/2 Improved Slam warrior's client actually reports,
// which is the half this client used to get wrong: the only input that moves is the `sec
// cast` line, and it has to move the window from 0.2 s of the swing to 0.7 s.
// `RefreshSlamCastTime` is the addon's own invalidator — its scan is cached for 2 s, and a
// test that re-pushed the tooltip without it would measure the stale value and pass.
s.set_spell_tooltip(
1464,
benilla_ui::script::SpellTooltipView {
name: "Slam".into(),
rank: Some("Rank 1".into()),
cost: Some("15 Rage".into()),
range: Some("Melee Range".into()),
cast_time: Some("2 sec cast".into()),
description: "Slams the opponent.".into(),
..Default::default()
},
);
let _ = s.eval::<()>("CleveRoids.RefreshSlamCastTime()");
assert_eq!(
ask(&mut s, "CleveRoids.GetSlamCastTime()"),
"2",
"`\"2 sec cast\"` parses — the addon's own regex is `(%d+%.?%d*) sec cast`, so the \
`%.3g` template's bare `2` has to match as readily as `2.5`"
);
assert_eq!(
ask(&mut s, "CleveRoids.GetSlamWindowPercent()"),
WINDOW_PCT_TALENTED,
"(2.70 - 2.00) / 2.70 — 0.7 s of a 2.7 s swing. This is the number his client should \
have been showing all along, and the only thing that changed to get it is the cast line"
);
// The `<OnUpdate>` delta: the timer has to run down in real seconds, or the window never
// closes and `[slamclip]` never takes over.
for _ in 0..6 {
+8 -5
View File
@@ -220,7 +220,7 @@ impl CastLadder<'_, '_> {
.and_then(|d| {
self.spells
.as_ref()
.map(|s| s.cast_time_ms(d, d.base_level))
.map(|s| s.cast_time_ms(d, d.base_level, &self.spell_mods))
})
.unwrap_or(0);
self.pending
@@ -500,8 +500,9 @@ fn send_spell_cast(
.self_store
.and_then(|s| s.0.unit_level())
.unwrap_or(0);
let press_cast_time =
def.map_or(0, |d| spells.map_or(0, |s| s.cast_time_ms(d, caster_level)));
let press_cast_time = def.map_or(0, |d| {
spells.map_or(0, |s| s.cast_time_ms(d, caster_level, spell_mods))
});
match np.rules.admit(
spell_id,
def,
@@ -790,7 +791,7 @@ fn send_spell_cast(
.self_store
.and_then(|s| s.0.unit_level())
.unwrap_or(0);
let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level));
let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level, spell_mods));
if validator::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) {
debug!("ui_action: cast {spell_id} refused locally — moving (0x2e)");
cast_errors.push_local(spell_id, 0x2e);
@@ -936,7 +937,9 @@ fn send_spell_cast(
.self_store
.and_then(|s| s.0.unit_level())
.unwrap_or(0);
let cast_time_ms = def.map_or(0, |d| spells.map_or(0, |s| s.cast_time_ms(d, caster_level)));
let cast_time_ms = def.map_or(0, |d| {
spells.map_or(0, |s| s.cast_time_ms(d, caster_level, spell_mods))
});
pending.arm(
spell_id,
now,
+1 -1
View File
@@ -33,7 +33,7 @@ pub(crate) use inflight::{
inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell,
SPELL_INTERRUPT_MOVEMENT,
};
pub(crate) use mods::{SpellModifiers, OP_COST};
pub(crate) use mods::{SpellModifiers, OP_CASTING_TIME, OP_COST};
// `TargetingWants` is exported for the ground reticle, which draws for the location word alone.
pub(crate) use targeting::{
ground_cast_radius, CorpsePick, PicksSelf, SpellTargeting, TargetingWants,
+10 -2
View File
@@ -15,8 +15,8 @@
//! boolean, so a bare `(flat, pct)` pair would zero every unmodified spell; hence
//! [`SpellModifiers::modifiers`] returns `Option` and [`SpellMod`] is private.
//!
//! All 29 ops are stored; only op 14 (`COST`) is read here. Op 24 is the one the client routes
//! aura 65 (casting speed) to, whatever vmangos names it.
//! All 29 ops are stored; two are read: op 14 ([`OP_COST`]) and op 10 ([`OP_CASTING_TIME`]). Op 24
//! is the one the client routes aura 65 (casting speed) to, whatever vmangos names it.
use bevy::prelude::*;
@@ -36,6 +36,14 @@ const CELLS: usize = BITS * OPS;
/// `6e32e3`).
pub(crate) const OP_COST: u8 = 14;
/// `SPELLMOD_CASTING_TIME`, read by [`crate::ui_action::Spells::cast_time_ms`]. The reference
/// applies it *inside* `Spell_C::GetCastTime` (`6e33b7: push $0xa`, `6e33be: call 0x6e6af0`),
/// after the level scale and the row's minimum clamp — so every caller of `GetCastTime` sees the
/// talent-shortened cast, the spellbook tooltip's own cast line included (`52eb4d: call
/// 0x6e3340`). Treating it as a call-site concern instead is what shipped a warrior's Slam as its
/// untalented 2.5 s for a whole release; see that method's doc.
pub(crate) const OP_CASTING_TIME: u8 = 10;
/// `SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS` (vmangos `SpellDefines.h:940`): a spell carrying it
/// takes no modifier (`0x6e6b52`).
const ATTR_EX3_IGNORE_CASTER_MODIFIERS: u32 = 0x2000_0000;
+5 -1
View File
@@ -974,13 +974,17 @@ fn cast_result(
/// Shoot are instant rows too. An unknown spell, or a build without the cast-time table, keeps the
/// reference's bar rather than losing every bar.
///
/// The row-only read ([`crate::ui_action::Spells::cast_time_row_ms`]) is deliberate: spell-mod op
/// `0xa` can take a real cast to zero, and a spell a talent made instant still is not a spell that
/// was *authored* instant. The bar follows the wire's own cast time either way.
///
/// Pulled out of [`spell_start`] because ClassicAPI's `C_Spell.UnitCastingInfo` has to answer the
/// *same* question (`ui_cast_events.rs`'s cast windows) — pfUI's cast bar replaces the stock one,
/// so if the two gates could disagree, one of the two bars would be wrong, and the whole point of
/// routing both off one wire read is that neither can be. One body, two callers.
fn opens_cast_bar(spells: Option<&crate::ui_action::Spells>, spell_id: u32) -> bool {
match (spells, spells.and_then(|s| s.catalog.get(spell_id))) {
(Some(s), Some(d)) if !s.cast_times.is_empty() => s.cast_time_ms(d, d.base_level) > 0,
(Some(s), Some(d)) if !s.cast_times.is_empty() => s.cast_time_row_ms(d, d.base_level) > 0,
_ => true,
}
}
+55 -4
View File
@@ -97,10 +97,14 @@ pub(crate) struct Spells {
}
impl Spells {
/// The cast time in ms (`GetCastTime 0x6e3340`), level-scaled from the `CastingTimeIndex`
/// row; a missing row reads 0. Spell-mod op `0xa` is not applied, so a talent-shortened cast
/// shows its base length.
pub(crate) fn cast_time_ms(
/// The `SpellCastTimes.dbc` row alone (`0x6e3340`'s first half): level-scaled and floored to
/// the row minimum, with no spell modifier applied. A missing row reads 0.
///
/// Only the "does this spell have a cast at all" question wants this
/// ([`crate::spell::net`]'s `opens_cast_bar`), because a modifier can take a real cast to zero
/// and that is not the same thing as a spell that was always instant. Everything asking how
/// long a cast will actually take wants [`Self::cast_time_ms`].
pub(crate) fn cast_time_row_ms(
&self,
def: &benilla_formats::SpellDisplay,
caster_level: u32,
@@ -109,6 +113,53 @@ impl Spells {
.get(def.casting_time_index)
.map_or(0, |row| row.resolved_ms(caster_level, def.base_level))
}
/// `Spell_C::GetCastTime(spell, unit, 1)` (`0x6e3340`): the row resolve, then spell-mod op
/// `0xa` (`SPELLMOD_CASTING_TIME`) applied in place (`6e33b7`). Signed, because the modifier
/// runs *after* the row's own clamp and can carry the result below zero — the reference's
/// `allowNegative` argument, which the tooltip passes as 1 (`52eb4b: push $0x1`) so that its
/// own ladder can read a negative as "Instant cast" (`0x52ebce`).
///
/// This is the step whose absence pinned `[noslamclip]` false for a whole release. The realm's
/// Slam is a 2.5 s cast (`CastingTimeIndex` 20 → 2500 ms) and Improved Slam is a flat −500 ms
/// on op 10 at 2/2 (`Spell.dbc` 12862/12330: aura 107, misc 10, basePoints −251/−501 with
/// `dieSides` 1), so a talented warrior's Slam is 2.0 s. On a 2.70 s weapon that is the
/// difference between a 0.70 s no-clip window and a 0.20 s one — 25.9% of the swing against
/// 7.4% — and a 0.20 s window is narrower than human reaction time, so the macro's Slam leg
/// never fired once in 477 presses while the reference client's fired every swing.
///
/// Deviation: the final `UNIT_MOD_CAST_SPEED` multiplier (`6e342c`, taken only when
/// `Attributes & 0x30` is clear and the unit argument is non-null) is not applied — no site
/// streams unit field 139 yet. It is 1.0 for every unit without a casting-speed aura, so it
/// moves nothing a physical ability does; a Curse of Tongues target's cast lines are the case
/// that still reads short.
pub(crate) fn cast_time_signed_ms(
&self,
def: &benilla_formats::SpellDisplay,
caster_level: u32,
mods: &crate::spell::SpellModifiers,
) -> i32 {
let row = self.cast_time_row_ms(def, caster_level);
mods.apply(
def,
crate::spell::OP_CASTING_TIME,
i32::try_from(row).unwrap_or(i32::MAX),
)
}
/// `Spell_C::GetCastTime(spell, unit, 0)`: [`Self::cast_time_signed_ms`] with a negative
/// clamped to zero (`6e3468`..`6e3470`, the `setle`/`dec`/`and` the reference reaches when its
/// third argument is 0). What every caller wanting a duration wants.
pub(crate) fn cast_time_ms(
&self,
def: &benilla_formats::SpellDisplay,
caster_level: u32,
mods: &crate::spell::SpellModifiers,
) -> u32 {
self.cast_time_signed_ms(def, caster_level, mods)
.max(0)
.unsigned_abs()
}
}
#[cfg(test)]
+16 -6
View File
@@ -148,11 +148,12 @@ fn spell_tooltip_view(
let cast_time = if d.tooltip_omits_cast_line() {
None
} else {
let base = spells
.cast_times
.get(d.casting_time_index)
.map(|c| c.base_ms as i32)
.unwrap_or(0);
// `GetCastTime(spell, unit, 1)` (`52eb48`-`52eb4d`), not the raw `SpellCastTimes.dbc`
// cell: level-scaled, and with spell-mod op `0xa` applied. Reading the cell directly is
// what made a talented warrior's Slam tooltip read its untalented 2.5 s, which is the one
// number `SuperCleveRoidMacros` scans this very line for.
let caster_level = vctx.store.and_then(|s| s.0.unit_level()).unwrap_or(0);
let base = spells.cast_time_signed_ms(d, caster_level, vctx.spell_mods);
// `%.3g` templates: the seconds go over as a real.
if base > 0 {
let (key, v) = if base >= 60_000 {
@@ -319,6 +320,8 @@ struct SpellFeedMemory {
avoidance: Option<[u32; 4]>,
/// The caster's and its auto-attack target's reach as bit patterns.
combat_reach: Option<(Option<u32>, Option<u32>)>,
/// The caster's level, which the cast line scales by (`0x6e3340`); a level-up re-pushes.
caster_level: Option<u32>,
/// Per reagent on show, `(owned count, name resolved)` (the inline red, `0x854120`).
reagents: std::collections::BTreeMap<u32, (u32, bool)>,
}
@@ -479,10 +482,17 @@ fn feed_spell_tooltips(
memory.combat_reach = Some(reaches);
wanted.extend(memory.pushed.drain());
}
// So does a spell-modifier change: the cost cell resolves through the talent tables.
// So does a spell-modifier change: the cost and cast cells both resolve through the talent
// tables, which is how a freshly-spent Improved Slam point reaches the cast line.
if spell_mods.is_changed() {
wanted.extend(memory.pushed.drain());
}
// And so does a level-up: the cast line is level-scaled.
let caster_level = self_store.and_then(|s| s.0.unit_level());
if memory.caster_level != caster_level {
memory.caster_level = caster_level;
wanted.extend(memory.pushed.drain());
}
let watched: Vec<u32> = memory.reagents.keys().copied().collect();
let reagent_state: std::collections::BTreeMap<u32, (u32, bool)> = watched
.into_iter()
@@ -628,3 +628,80 @@ fn a_late_creature_answer_still_rebuilds_the_plate() {
"a name and a subtitle are lines: they must rebuild the plate"
);
}
/// Slam's cast line with Improved Slam at 2/2, which is the one number
/// `SuperCleveRoidMacros` reads to size its `[noslamclip]` window.
///
/// The realm's own data: `Spell.dbc` 1464 is `CastingTimeIndex` 20, and `SpellCastTimes.dbc`
/// row 20 is `{base 2500, perLevel 0, minimum 2500}`; Improved Slam (12862 Rank 1, 12330 Rank 2)
/// is aura 107 / misc 10 / `basePoints` −251 and −501 with `dieSides` 1, i.e. a flat −250 or −500
/// on spell-mod op `0xa` against `SpellFamilyFlags` bit 21 — Slam's own bit, in family 4.
/// 2500 − 500 = 2000.
///
/// Two things this pins beyond the number. The **order**: the reference clamps to the row's
/// minimum at `6e33b0` and applies the modifier at `6e33b7`, so with a minimum of 2500 a
/// modifier applied before the clamp would leave Slam at 2.5 s for ever. And the **route**: the
/// tooltip's cast line is `GetCastTime(spell, unit, 1)` (`52eb4d`), not a `SpellCastTimes.dbc`
/// read, which is what reading the row directly got wrong.
#[test]
fn a_talent_shortened_cast_line_shows_its_talented_length() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
const SLAM: u32 = 1464;
/// Slam's `SpellFamilyFlags` bit, and the warrior family it is gated by.
const SLAM_BIT: u8 = 21;
const WARRIOR_FAMILY: u32 = 4;
let mut objs = no_objects();
let objects = objs.get();
// Untalented, which is what every other cell in this file is graded as.
let mut t = TestCtx::new();
let untalented = spell_tooltip_view(SLAM, &spells, &mut t.ctx(&objects, 0, None))
.expect("Slam view")
.cast_time;
assert_eq!(
untalented.as_deref(),
Some("2.5 sec cast"),
"the realm's Slam is a 2.5 s cast before any talent — vanilla's is 1.5"
);
// 2/2 Improved Slam, as the wire delivers it: one absolute cell, flat, (bit 21, op 10).
let mut t = TestCtx::new();
t.spell_mods.set_class_family(WARRIOR_FAMILY);
t.spell_mods
.set(true, SLAM_BIT, crate::spell::OP_CASTING_TIME, -500);
let talented = spell_tooltip_view(SLAM, &spells, &mut t.ctx(&objects, 0, None))
.expect("Slam view")
.cast_time;
assert_eq!(
talented.as_deref(),
Some("2 sec cast"),
"op 0xa is applied inside `GetCastTime`, so the cast line is the talented length. \
Reading `SpellCastTimes.dbc` directly left this at 2.5, which cut the macro's no-clip \
window on a 2.70 s weapon from 0.70 s to 0.20 s — narrower than a human's reaction \
time, so its Slam leg never once fired in 477 presses"
);
// The one cell this spell's own family gate must not leak into: a mage's modifier table
// cannot shorten a warrior's Slam, whatever bit it sets.
let mut t = TestCtx::new();
t.spell_mods.set_class_family(3);
t.spell_mods
.set(true, SLAM_BIT, crate::spell::OP_CASTING_TIME, -500);
assert_eq!(
spell_tooltip_view(SLAM, &spells, &mut t.ctx(&objects, 0, None))
.expect("Slam view")
.cast_time,
untalented,
"`GetSpellModifiers` gates on the spell's family equalling the player's own (`6e6b30`)"
);
}
+91
View File
@@ -1450,3 +1450,94 @@ does nothing, and reports a clean load over an empty screen. `Step::Plates(n)` s
plates through `sync_nameplates` and counts how many an addon has hung a frame on — which doubles
as the contract check on the plate's own shape, since a plate that stopped being a `Button`, or
stopped putting its border texture first, would read as an addon adopting nothing.
## The slam macro: a talent the engine never applied
`SuperCleveRoidMacros`' `[noslamclip]` picked Heroic Strike every time — 477 casts in grom's own
`WoWCombatLog.txt`, Slam not once — on a macro he reports working on the stock client. Three
answers were wrong before the right one, and all three are worth the space, because each of the
first two came with a green test.
### How the conditional is actually built
`Conditionals.lua` sizes the window itself:
```lua
local maxSlamStart = attackSpeed - CleveRoids.GetSlamCastTime()
if maxSlamStart <= 0 then return 0 end
return (maxSlamStart / attackSpeed) * 100 -- GetSlamWindowPercent
...
return CleveRoids.GetSwingPercentElapsed() <= maxPercent -- ValidateNoSlamClip
```
`GetSlamCastTime()` is not a verb call. It walks the spellbook with
`GetSpellName(i, BOOKTYPE_SPELL)` for Slam, builds a **nil-parent `GameTooltipTemplate`** scanning
tooltip, calls `tooltip:SetSpell(slot, bookType)`, and reads `<name>TextLeft<i>` with `getglobal`
looking for `"%d+%.?%d* sec cast"` — with `DEFAULT_SLAM_CAST = 2.5` as its fallback. So the whole
conditional rests on one line of our spellbook tooltip, and on this realm a failed scan and a
correct scan return the same 2.5, which is why the number alone never discriminated.
### The three answers
| | Reading | Why it was wrong |
|---|---|---|
| 1 | Harness test, fixture `"1.5 sec cast"`, 44% window → "the chain is healthy" | 1.5 s is vanilla's Slam. Nothing on this realm ships it; the number was invented. |
| 2 | Fixture corrected off the realm's DBC to 2.5 s, 7.4% window → "the macro's own arithmetic, not ours" | The fixture was right. The conclusion was not: 2.5 is the *untalented* cast. |
| 3 | Our tooltip never applied spell-mod op `0xa` | His client reports **2.0 s**, not 2.5. |
What settled it was one live probe printing every term at once —
`mx=2.7 st=1.0255 u=2.7 c=2.5 w=7.4074` — which showed the swing speed and the swing timer both
healthy and left exactly one term standing.
### The defect
`Spell_C::GetCastTime` (`0x6e3340`) applies `SPELLMOD_CASTING_TIME` **inside itself**:
```
6e33aa: mov %eax,-0x4(%ebp) ; base + perLevel·(level − baseLevel)
6e33b0: cmp %esi,%eax ; row minimum
6e33b4: mov %esi,-0x4(%ebp) ; …clamped to it
6e33b7: push $0xa ; op 10 = SPELLMOD_CASTING_TIME
6e33be: call 0x6e6af0 ; value = (value + flat) · pct / 100
```
and the tooltip's cast line is a call to it, not a DBC read (`52eb48: mov 0x8(%ebx),%ecx` /
`52eb4b: push $0x1` / `52eb4d: call 0x6e3340`). We read `SpellCastTimes.dbc` directly, so a
talent-shortened cast showed its base length — a deviation `ui_action/mod.rs` and `cast_times.rs`
both *documented* without anyone pricing it.
The realm's numbers make the price concrete. Slam (1464/8820/11604/11605) is `CastingTimeIndex`
20, and row 20 is `{base 2500, perLevel 0, minimum 2500}`. Improved Slam is two ranks — 12862 and
12330, aura 107 / misc 10 / `basePoints` −251 and −501 with `dieSides` 1, a flat −250 or −500 ms
on op 10 against `SpellFamilyFlags` bit 21, family 4 (the five-rank version is `zzOLD`). So a 2/2
warrior casts Slam in 2.0 s.
On his 2.70 s main hand:
| Slam cast | no-clip window | share of the swing |
|---|---|---|
| 2.5 s (ours) | 0.20 s | 7.4% |
| 2.0 s (his client) | 0.70 s | 25.9% |
It is a size, not a sign. 0.20 s is narrower than human reaction time, so a player pressing by
hand overshoots it every press; 0.70 s he lands in every swing. 477-to-0 and "works just fine on
the normal client" are the same observation.
Note the **order**: the minimum clamp is 2500 and it runs *before* the modifier. Applying op
`0xa` before the clamp would pin Slam at 2.5 s for ever and look exactly like the bug it was
meant to fix.
### What changed
`Spells::cast_time_ms` is now `GetCastTime` — row resolve, minimum clamp, then op `0xa` — with
`cast_time_signed_ms` for the reference's `allowNegative` form that the tooltip's "Instant cast"
ladder needs (`0x52ebce`), and `cast_time_row_ms` kept for the one question that wants the row
alone: whether a spell has a cast time *authored* at all, which is `opens_cast_bar`'s gate.
The four live readers — the two `PendingCast` arms, the nampower press window and the
moving-refusal — and the tooltip's cast line all pass the modifier tables through. The tooltip
feed re-pushes on a level change too, since the cast line is level-scaled now.
Still deviating, and now written where it lives: the final `UNIT_MOD_CAST_SPEED` multiplier
(`6e342c`, taken only when `Attributes & 0x30` is clear) is not applied, because no site streams
unit field 139. It is 1.0 without a casting-speed aura, so it moves nothing a physical ability
does — a Curse of Tongues target's cast lines are the case that still reads short.