The slam macro, end to end through the path the game takes

He re-tested and it still never casts Slam, so I drove the whole thing rather
than the conditional: `UseAction` on a macro slot → `run_macro` fires
`EXECUTE_CHAT_LINE` per line (`0x4f14e0`) → the stock ChatFrame sends it through
its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
`SlashCmdList["CAST"]` → CleveRoids' hook → `GetParsedMsg` → `DoWithConditionals`
→ `Keywords.noslamclip` → `ValidateNoSlamClip` → `CastSpellByName`. On his own
interface, with his own 2.70 s weapon and Battle Stance, the line casts **Slam**
at the top of a swing and **Heroic Strike** 1.6 s in — which is the assertion the
old test never made, and the reason "the conditional answers true" meant nothing.

Three things came out of the drive.

**The load-time latch is worse than I wrote this morning, and it is what he is
seeing.** `GetSlamWindowPercent` reads SP_SwingTimer's `st_timerMax` cache, not
`UnitAttackSpeed`, so the `ADDON_LOADED` latch decides the window for the whole
first fight: the stand-in gives (2.0 - 1.5)/2.0 = **25%** against his weapon's
44%, while `st_timer` is still 0 so the swing reads **100% elapsed** — and
`[noslamclip]` is false with nothing said about it. Before the stand-in it was
false *and* printed "requires SP_SwingTimer" once; the fix traded a visible
failure for a silent one. Both states are pinned in the test now.

So the sheet is **seeded before the addon walk**, beside the roster record and
unit snapshot that are already seeded there for the same reason
(`ui_char::seed_combat_stats`). A `/reload` — and anyone mid-fight — now latches
the real weapon. A fresh login still has no self store (the create block has not
arrived, which is why the record comes off the roster), so the stand-in stands
there and heals on the first swing.

**And the stand-in now says so in the log.** One line per VM per token, through
the channel `/errors` lists: a combat-stats verb answering for "player" before
anything pushed that unit's sheet. One such read is expected at load; what the
line is for is the case where it never stops, which no test of the verb can see
because every one of them seats the sheet first. `UnitAttackSpeed` reaching Lua
with the store's own `UNIT_FIELD_BASEATTACKTIME` is now asserted through the real
`feed_char`, which is the test that was missing underneath all of this.

The dual-wield road is pinned too, because it is the one shape that can strand
the main-hand timer: `CheckDamageSource` returns "MAIN" on line one for a
two-hander and never runs its arithmetic, but dual-wielding it weighs the hit
against `UnitDamage`'s ranges scaled by the target's armour — and
`UnitArmor("target")` is 0 on any 1.12 client, ours included, because another
unit's resistances are not public. Every hit then reads UNKNOWN and falls to
`ClosestSwing == true or HeroicStrikeQueued()`, whose first half compares against
a *function* and whose second is true whenever this very macro has queued a
Heroic Strike — and `ResetTimer(true)` refills the **off-hand** timer while
`st_timer` runs to 0, where the elapsed read is 100 for good. That loop is the
addon's own and reproduces on any client; what we must not do is enter it when
the player is two-handed, which is `has_offhand` alone, and his combat log says
two-handed: 2.70, 2.71, 2.72, 2.68 between consecutive hits, with no 0.2 s
off-hand pairing anywhere.

189 failures, unchanged, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-29 17:33:31 -06:00
co-authored by Claude Opus 5
parent d89192d962
commit 3bbd99ebaf
6 changed files with 399 additions and 12 deletions
@@ -1525,6 +1525,275 @@ fn a_cursor_seated_sign_plate_prices_its_own_re_anchor() {
}
}
/// **grom's whole macro line**, driven the way the game drives it, on his own interface:
///
/// ```text
/// #showtooltip
/// /cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike; [stance:2] Shield Slam
/// ```
///
/// The sibling test below drives the *conditional*. This one drives the **path**, because the
/// conditional answering `true` in isolation is what let two sessions of chasing this go past the
/// real question — which is whether that answer survives the eight links between a key press and a
/// spell id on the wire:
///
/// `UseAction` on a macro slot → `ui_macro::run_macro` fires `EXECUTE_CHAT_LINE` per line
/// (`0x4f14e0`, reached only from `UseAction` at `0x4e6098`) → the stock `ChatFrame_OnEvent` arm
/// sends the line through its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
/// `SlashCmdList["CAST"]` → **CleveRoids' hook** → `GetParsedMsg` splits the three clauses →
/// `DoWithConditionals` walks the conditionals through `CleveRoids.Keywords` → `noslamclip` →
/// `ValidateNoSlamClip` → `CastSpellByName`.
///
/// Both arms are asserted, and the second is the one that makes the first mean anything: at the
/// top of a swing the line casts **Slam**, and 1.6 s into a 2.70 s swing — 59% elapsed, past the
/// 44% window — it casts **Heroic Strike**. A chain that always answered one or the other would
/// pass half of this.
#[test]
fn the_whole_slam_macro_line_picks_slam_early_and_heroic_strike_late() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(root) = crate::ui_script::addons::root() else {
eprintln!("slam-macro probe: no benilla-config/AddOns — nothing to ask");
return;
};
let mut s = load_the_players_ui(&root);
seat_a_warrior_mid_fight(&mut s, false);
let ask = |s: &mut UiScript, chunk: &str| -> String {
s.eval::<String>(&format!(
"local ok, v = pcall(function() return {chunk} end) return tostring(v)"
))
.unwrap_or_else(|e| format!("<{e}>"))
};
// The three inputs the window is computed from, each named so a regression says which moved.
assert_eq!(
ask(&mut s, "CleveRoids.GetCurrentShapeshiftIndex()"),
"1",
"Battle Stance, which is the `stance:1/3` half of every clause"
);
assert_eq!(
ask(&mut s, "CleveRoids.GetSlamCastTime()"),
"1.5",
"the cast time came off the scanning tooltip, not the addon's 2.5 s fallback — against a \
2.70 s swing the fallback leaves a 7% window instead of 44%, which in play reads as \
`[noslamclip]` never being true at all"
);
// **Before the first swing of the session**, and this is the state a player meets when he
// opens on a mob with this macro. `st_timerMax` is SP_SwingTimer's `ADDON_LOADED` latch, which
// is whatever `UnitAttackSpeed` answered before `feed_char`'s first push — the
// `VANILLA_SWING_MS` stand-in — and `st_timer` is still 0, so the whole swing reads as
// elapsed:
//
// window = (2.0 - 1.5) / 2.0 = 25%, elapsed = 100% -> `[noslamclip]` false -> Heroic Strike.
//
// That is correct of the numbers it has and wrong about the weapon, and it heals on the first
// auto-attack that lands (below). It is also why the latch is worth a log line
// (`char_stats::with_unit_stats`): if the sheet never arrives, this *is* the session.
assert_eq!(ask(&mut s, "st_timerMax"), "2");
assert_eq!(ask(&mut s, "CleveRoids.GetSwingPercentElapsed()"), "100");
assert_eq!(ask(&mut s, "CleveRoids.GetSlamWindowPercent()"), "25");
assert_eq!(
ask(&mut s, "CleveRoids.ValidateNoSlamClip()"),
"false",
"with no swing timer running yet the macro picks Heroic Strike, and nothing is raised \
about it"
);
const LINE: &str = "/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike; [stance:2] Shield Slam";
/// Slam, Heroic Strike: the two ids this line can resolve to.
const SLAM: u32 = 1464;
const HEROIC_STRIKE: u32 = 78;
// One auto-attack lands: `st_timer` is full, so none of the swing has elapsed.
s.fire_event(
"CHAT_MSG_COMBAT_SELF_HITS",
vec![benilla_ui::script::ScriptValue::Str(
"You hit Apprentice Training Dummy for 46.".into(),
)],
);
assert_eq!(
ask(&mut s, "st_timerMax"),
"2.7",
"the swing reached `SP_ST_OnEvent` with a parsable `arg1` and `ResetTimer` replaced the \
load-time latch with the real weapon speed"
);
assert_eq!(ask(&mut s, "CleveRoids.GetSwingPercentElapsed()"), "0");
assert_eq!(
ask(&mut s, "CleveRoids.GetSlamWindowPercent()"),
"44.444444444444",
"(2.70 - 1.50) / 2.70, off the real weapon this time"
);
let _ = s.take_spell_casts();
s.fire_event(
"EXECUTE_CHAT_LINE",
vec![benilla_ui::script::ScriptValue::Str(LINE.into())],
);
assert_eq!(
s.take_spell_casts(),
vec![SLAM],
"at the top of a swing the line has to reach Slam — if this is Heroic Strike, read \
`GetSwingPercentElapsed` and `GetSlamWindowPercent` above before anything else"
);
// 1.6 s later the swing is 59% elapsed, past the window: the second clause takes it.
s.tick(1.6);
assert_eq!(
ask(&mut s, "CleveRoids.ValidateNoSlamClip()"),
"false",
"59% elapsed is past the 44% window, so casting Slam now would clip the auto-attack"
);
let _ = s.take_spell_casts();
s.fire_event(
"EXECUTE_CHAT_LINE",
vec![benilla_ui::script::ScriptValue::Str(LINE.into())],
);
assert_eq!(
s.take_spell_casts(),
vec![HEROIC_STRIKE],
"late in the swing the line has to fall through to Heroic Strike"
);
}
/// The same line with an **off-hand weapon**, which takes a different road through
/// SP_SwingTimer and is the one shape that can strand the main-hand timer.
///
/// `CheckDamageSource` returns `"MAIN"` on its first line for a two-hander
/// (`SP_SwingTimer.lua:339`), so none of its damage arithmetic runs. Dual-wielding, it instead
/// weighs the hit against `UnitDamage`'s two ranges scaled by the target's armor — and
/// `UnitArmor("target")` answers 0 on any 1.12 client, ours included, because another unit's
/// resistances are not in the public descriptor (the addon says as much at
/// `SP_SwingTimer.lua:299`). So every hit reads `"UNKNOWN"`, and the fall-through is
/// `ClosestSwing == true or HeroicStrikeQueued()` — the first of which is a comparison against a
/// *function* and can never be true, and the second of which is true whenever a Heroic Strike is
/// queued, which this very macro leaves queued. `ResetTimer(true)` then refills the **off-hand**
/// timer and leaves `st_timer` running down to 0, where `GetSwingPercentElapsed` reads 100 and
/// `[noslamclip]` is false for good.
///
/// That loop is the addon's own and reproduces on any client; what this test pins is that we do
/// not *enter* it when the player is two-handed. `has_offhand` is the only input that decides
/// (`ui_char::combat_stats` reads the off-hand slot's template class, so a shield is not a
/// weapon), and `UnitAttackSpeed`'s second return is `nil` on it — which is the whole of
/// `isDualWield()`.
#[test]
fn the_slam_macro_line_holds_up_with_an_off_hand_weapon() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(root) = crate::ui_script::addons::root() else {
return;
};
let mut s = load_the_players_ui(&root);
seat_a_warrior_mid_fight(&mut s, true);
let ask = |s: &mut UiScript, chunk: &str| -> String {
s.eval::<String>(&format!(
"local ok, v = pcall(function() return {chunk} end) return tostring(v)"
))
.unwrap_or_else(|e| format!("<{e}>"))
};
assert_eq!(
ask(&mut s, "select(2, UnitAttackSpeed('player'))"),
"1.8",
"an off-hand weapon answers a second speed, and that *is* `isDualWield()`"
);
s.fire_event(
"CHAT_MSG_COMBAT_SELF_HITS",
vec![benilla_ui::script::ScriptValue::Str(
"You hit Apprentice Training Dummy for 46.".into(),
)],
);
assert_eq!(
ask(&mut s, "st_timerMax"),
"2.6",
"the main-hand timer still armed: with nothing in an action slot to answer \
`HeroicStrikeQueued()`, the UNKNOWN branch falls through to `ResetTimer(false)`"
);
let _ = s.take_spell_casts();
s.fire_event(
"EXECUTE_CHAT_LINE",
vec![benilla_ui::script::ScriptValue::Str(
"/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike".into(),
)],
);
assert_eq!(s.take_spell_casts(), vec![1464]);
}
/// A warrior in Battle Stance with a target, Slam/Heroic Strike/Shield Slam known and tooltipped,
/// and his own 2.70 s main hand — what the live feeds push and `load_the_players_ui` does not.
/// `offhand` adds a 1.80 s off-hand weapon, which is what `isDualWield()` reads.
fn seat_a_warrior_mid_fight(s: &mut UiScript, offhand: bool) {
s.set_player_combat_stats(Some(benilla_ui::script::UnitCombatStats {
main_attack_time_ms: if offhand { 2600 } else { 2700 },
offhand_attack_time_ms: 1800,
has_offhand: offhand,
min_damage: 41.0,
max_damage: 51.0,
min_offhand_damage: 20.0,
max_offhand_damage: 30.0,
..Default::default()
}));
for (id, name, cast) in [
(1464u32, "Slam", Some("1.5 sec cast".to_string())),
(78, "Heroic Strike", None),
(23922, "Shield Slam", None),
] {
s.set_spell_tooltip(
id,
benilla_ui::script::SpellTooltipView {
name: name.into(),
rank: Some("Rank 1".into()),
cost: Some("15 Rage".into()),
range: Some("Melee Range".into()),
cast_time: cast,
description: "Strikes the opponent.".into(),
..Default::default()
},
);
}
s.set_spellbook(benilla_ui::script::SpellBookState {
tabs: Vec::new(),
slots: [
(1464u32, "Slam"),
(78, "Heroic Strike"),
(23922, "Shield Slam"),
]
.iter()
.map(|(id, name)| benilla_ui::script::SpellSlotView {
spell_id: *id,
name: (*name).into(),
..Default::default()
})
.collect(),
});
// The three warrior stances, Battle active: `GetCurrentShapeshiftIndex` walks
// `GetShapeshiftFormInfo` for the active one, and every clause of his macro gates on it.
s.set_shapeshift_forms(
[(2457u32, true), (71, false), (2458, false)]
.iter()
.map(|(id, active)| benilla_ui::script::ShapeshiftFormView {
spell_id: *id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Stance {id}"),
active: *active,
castable: true,
cooldown: None,
})
.collect(),
);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
name: Some("Apprentice Training Dummy".into()),
health: 100,
max_health: 100,
level: 14,
guid: 0x7001,
..Default::default()
}),
);
}
/// SuperCleveRoidMacros' `[noslamclip]` conditional, end to end, on grom's own interface.
///
/// He reports that `/cast [noslamclip, stance:1/3] Slam; [slamclip, stance:1/3] Heroic Strike`
+73
View File
@@ -1004,6 +1004,40 @@ pub(crate) fn fire_stat_transitions(
}
}
/// Push the player's own combat sheet into a freshly built VM, **before its addon walk**.
///
/// `load_ingame_ui_on_world_entry` seeds the player's roster record and unit snapshot for exactly
/// this reason — an addon reads them at file scope — and the sheet belongs in the same seat,
/// because the one number everything melee divides by is latched there. SP_SwingTimer caches
/// `UnitAttackSpeed("player")` into `st_timerMax` in its `ADDON_LOADED` arm behind an
/// `if not st_timerMax`, and CleveRoids computes its `[noslamclip]` window from that cache rather
/// than from the verb, so a stand-in read at load is the window for the whole first fight:
/// (2.0 - 1.5)/2.0 = 25% where the player's 2.70 s weapon gives 44%, with the swing reading 100%
/// elapsed until the first auto-attack lands and `ResetTimer` replaces it.
///
/// A fresh login has no self store yet — the create block has not arrived, which is why the record
/// comes off the roster — and there is nothing to seed; `feed_char` pushes a frame or two later
/// and the latch heals on the first swing. A `/reload`, which is the common case for an addon
/// author and for anyone mid-fight, has the store and gets the real weapon.
pub(crate) fn seed_combat_stats(world: &mut World, script: &mut UiScript) {
// Every resource optional and read before the `SystemState` is built: a world-entry test
// world holds neither `Items` nor `NetCommands`, and a `SystemState` over a missing `Res`
// panics on `get` rather than answering `None`.
if !world.contains_resource::<Items>() || !world.contains_resource::<NetCommands>() {
return;
}
let mut state = bevy::ecs::system::SystemState::<(
crate::items::Inventory,
Res<Items>,
Res<NetCommands>,
)>::new(world);
let (inv, items, commands) = state.get(world);
let Some(store) = inv.self_store.iter().next() else {
return;
};
script.set_player_combat_stats(Some(combat_stats(store, &inv.objects, &items, &commands)));
}
pub(crate) fn feed_char(
// `ChrClasses.dbc` field 16, the relic flag `find_equip_slot` reads; absent, no class has one.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
@@ -1435,6 +1469,8 @@ mod tests {
/// `PLAYER_FIELD_INV_SLOT_HEAD` plus the main hand's two words (`EQUIPMENT_SLOT_MAINHAND` is
/// 0-based 15, live id 16).
const F_MAIN_HAND: u16 = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15;
/// `UNIT_FIELD_BASEATTACKTIME` (`OBJECT_END+0x78`), the main hand's slot.
const F_BASE_ATTACK_TIME: u16 = benilla_protocol::field::FIELD_UNIT_BASEATTACKTIME;
/// `ITEM_FIELD_DURABILITY` / `ITEM_FIELD_MAXDURABILITY`, absolute descriptor indices.
const F_ITEM_DURABILITY: u16 = 46;
const F_ITEM_MAXDURABILITY: u16 = 47;
@@ -1475,6 +1511,10 @@ mod tests {
(F_MAIN_HAND, SWORD as u32),
(F_MAIN_HAND + 1, (SWORD >> 32) as u32),
(F_PLAYER_BYTES_3, 8 << 24),
// A 2.70 s main hand and no off hand, which
// `the_players_swing_speed_reaches_the_verb_through_the_feed` reads back through
// the verb. The other tests here price repairs and do not look at it.
(F_BASE_ATTACK_TIME, 2700),
])),
));
let mut sword = crate::items::test_template("Sword");
@@ -1612,6 +1652,39 @@ mod tests {
);
}
/// `UnitAttackSpeed("player")` answers the store's own `UNIT_FIELD_BASEATTACKTIME`, through
/// the real feed, on the frame the store arrives.
///
/// This is the one assertion the VM-side tests could not make, and the gap cost two sessions:
/// every swing-timer test seats the sheet with `set_player_combat_stats` and then asks, which
/// proves the *verb* and says nothing about whether anything ever calls the setter. The whole
/// `[noslamclip]` chain divides by this number — SP_SwingTimer latches it into `st_timerMax`
/// at `ADDON_LOADED` and CleveRoids computes its window from that — so a feed that never
/// pushed would leave the stand-in in place for the session and the macro's window wrong, with
/// nothing raised anywhere.
#[test]
fn the_players_swing_speed_reaches_the_verb_through_the_feed() {
// The store carries the wire's own `UNIT_FIELD_BASEATTACKTIME`: 2700 ms, no off hand.
let mut app = world_wearing_a_damaged_sword();
app.world_mut().run_system_once(feed_char).unwrap();
let script = app.world().non_send_resource::<UiScript>();
assert_eq!(
script
.eval::<f64>(r#"return (UnitAttackSpeed("player"))"#)
.expect("UnitAttackSpeed answers"),
2.7,
"the store's own base attack time has to reach Lua, not the `VANILLA_SWING_MS` \
stand-in `UnitCombatStats::default()` holds before the first push"
);
assert!(
script
.eval::<bool>(r#"return select(2, UnitAttackSpeed("player")) == nil"#)
.expect("the second return"),
"no off-hand weapon is `nil`, not a number — SP_SwingTimer's `isDualWield()` is \
exactly this read, and a false positive sends its timer resets to the wrong hand"
);
}
/// The equipped band's repair quote is `0x4faf30`'s own number, not 0 — which is what the
/// stock paper-doll tooltip's "Repair Cost" line and pfUI's durability panel both read. With
/// no vendor open it is the full list price, because `0x4faf30` takes a discount only once it
@@ -815,6 +815,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
{
script.set_unit("player", Some(seat));
}
// …and his combat sheet, whose swing speed an addon latches at `ADDON_LOADED` and never
// re-reads. Absent on a fresh login, where there is no self store yet.
crate::ui_char::seed_combat_stats(world, &mut script);
// The addon version gate, read from the persisted base the fold above just made current, so a
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
let version_check = world
@@ -111,7 +111,7 @@ const FIELD_UNIT_VIRTUAL_ITEM_INFO: u16 = 40;
/// Not `FIELD_PLAYER_BYTES_2`.
const FIELD_UNIT_BYTES_2: u16 = 164;
// UNIT combat and stat block: offset from OBJECT_END, then the wire type.
const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT
pub const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT
const FIELD_UNIT_RANGEDATTACKTIME: u16 = 128; // +0x7A, INT
const FIELD_UNIT_MINDAMAGE: u16 = 134; // +0x80, FLOAT
const FIELD_UNIT_MAXDAMAGE: u16 = 135; // +0x81, FLOAT
@@ -188,10 +188,10 @@ pub mod field {
FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_DYN_FLAGS, FIELD_GAMEOBJECT_STATE,
FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_FLAGS, FIELD_PLAYER_INV_SLOT_HEAD,
FIELD_PLAYER_QUEST_LOG_1_1, FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_AURA,
FIELD_UNIT_AURAAPPLICATIONS, FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BYTES_1,
FIELD_UNIT_CHANNEL_SPELL, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FACTIONTEMPLATE,
FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH,
FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS,
FIELD_UNIT_AURAAPPLICATIONS, FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BASEATTACKTIME,
FIELD_UNIT_BYTES_1, FIELD_UNIT_CHANNEL_SPELL, FIELD_UNIT_DYNAMIC_FLAGS,
FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL,
FIELD_UNIT_MAXHEALTH, FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS,
};
}
+45 -7
View File
@@ -427,14 +427,52 @@ fn with_unit_stats<T>(
token: &Option<String>,
f: impl FnOnce(&UnitCombatStats) -> T,
) -> T {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let absent = UnitCombatStats::default();
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
f(pushed.unwrap_or(&absent))
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
if let Some(stats) = pushed {
return f(stats);
}
}
// No sheet for this token: the answer is the stand-in, and for `"player"` that is worth one
// line in the log.
//
// Every addon that reads a swing speed, a damage range or an armour value divides by it, and
// several latch it: SP_SwingTimer caches `UnitAttackSpeed("player")` into `st_timerMax` at
// `ADDON_LOADED` behind an `if not st_timerMax` that a number satisfies, and CleveRoids
// computes its whole `[noslamclip]` window from that cache. A stand-in read there is wrong by
// the ratio of the real weapon to the vanilla 2000 — (2.0 - 1.5)/2.0 = 25% where the player's
// 2.70 s weapon gives 44% — and nothing anywhere raises. The window between a VM's birth and
// the first `feed_char` push always contains the addon walk, so one such read is *expected*;
// what this line is here to catch is the case where it never stops, which no test of the verb
// can see because every one of them seats the sheet first.
//
// Once per VM per token, through the same channel `/errors` lists: a per-call warning on a
// verb pfUI polls four times a second would be the log's whole content.
if let Some(token) = token.as_deref().filter(|t| *t == "player" || *t == "pet") {
let first = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.stats_stand_in_warned.insert(token.to_string())
};
if first {
crate::script::diagnostics::record_warning(
lua,
&format!(
"a combat-stats verb answered for \"{token}\" before anything pushed that \
unit's sheet, so it answered the stand-in ({} ms swing, zero damage). \
Expected once at load; if it is the last word for the session, `feed_char` \
never pushed and every addon dividing by a swing speed is wrong.",
VANILLA_SWING_MS
),
);
}
}
f(&absent)
}
/// The clamp `UnitDefense` (`0x519298`) and each hand of `UnitAttackBothHands` (`0x5188a7`) apply:
+4
View File
@@ -1449,6 +1449,9 @@ pub(crate) struct Model {
pub(crate) player_combat_stats: Option<char_stats::UnitCombatStats>,
/// The pet's combat stats, which the reference's pet sheet reads as `UnitStat("pet", …)`.
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
/// The tokens a combat-stats verb has already answered the stand-in for, so the warning it
/// records is one line per VM rather than one per call (`char_stats::with_unit_stats`).
pub(crate) stats_stand_in_warned: std::collections::HashSet<String>,
pub(crate) inventory_slots: char_stats::InventorySlots,
/// The six bank-bag slots, ids 64..=69, fed from the player descriptor's guids.
pub(crate) bank_bag_slots: char_stats::BankBagSlots,
@@ -2343,6 +2346,7 @@ impl Model {
.collect(),
player_combat_stats: None,
pet_combat_stats: None,
stats_stand_in_warned: Default::default(),
inventory_slots: Default::default(),
bank_bag_slots: Default::default(),
inventory_alerts: [0; 12],