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:
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user