Pet cooldowns flush at each packet's place, as 1.12.1's do (#2421)

In 1.12.1 the pet's cooldown list (list 1) flushes at each packet's own place. `PET_BAR_UPDATE_COOLDOWN` fires from five sites: `0x6e2e8e`, `0x6e3080`, `0x6e8601`, `0x6e95b9` and `0x6e971c`. The pet getters query that list live (`GetPetActionCooldown` at `0x4be03c`, plus the pet book and bar at `0x4b40dd` and `0x4e6cf7`). benilla flushed the pet's list once a frame and read it from the frame's final state.

Now the pet's list logs and replays like the player's (#2414), on its own lane in packet order, and the three getters read it live. A failed `CAST_RESULT` fires its GCD-clear pair (`0x6e167d`) before the pop (`0x6e2218`), so the cast slot and the targeting word change only at the pop.

The tests cover two pet packets in one frame, a player packet then a pet packet, and the GCD-clear pair seeing the cast still held. Each fails without its piece.

Part of #2217

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-25 22:50:51 +07:00
committed by GitHub
parent aadbf8823b
commit d814179f7c
25 changed files with 553 additions and 266 deletions
@@ -369,3 +369,94 @@ fn a_failed_cast_result_pops_the_slot_at_its_place_in_the_drain() {
]
);
}
/// A failed `SMSG_CAST_RESULT`'s report clears the GCD the send armed and fires its pair
/// (`0x6e1d89` → `0x6e1630`, the pair at `0x6e167d`) before it pops the slot (`0x6e2218`): the
/// pair's `SPELL_UPDATE_COOLDOWN` reads Heal still in flight, and the pop's
/// `CURRENT_SPELL_CAST_CHANGED` and `SPELLCAST_FAILED` after it read it popped.
#[test]
fn a_failed_results_gcd_clear_reads_the_cast_still_in_flight() {
let mut c = client();
c.app.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
HEAL,
SpellDisplay {
id: HEAL,
name: "Heal".into(),
icon: Some("Interface\\Icons\\Spell_Holy_Heal".into()),
start_recovery_category: 133,
start_recovery_ms: 1500,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
c.frame(vec![]);
c.seen(&[]);
cast_heal(&mut c);
c.frame(vec![vec![SessionEvent::CastResult {
spell_id: HEAL,
success: false,
reason: Some(0x0b),
arg: None,
}]]);
assert_eq!(
c.seen(&[
"SPELL_UPDATE_COOLDOWN",
"CURRENT_SPELL_CAST_CHANGED",
"SPELLCAST_FAILED",
]),
[
"SPELL_UPDATE_COOLDOWN:1:nil",
"CURRENT_SPELL_CAST_CHANGED:nil:nil",
"SPELLCAST_FAILED:nil:nil",
]
);
}
/// `CancelCast 0x6e4940` drops the targeting word (`0x6e4955`) where it pops the slot
/// (`0x6e49d6`), at the failed report's pop (`0x6e2218`), after the GCD clear's pair
/// (`0x6e167d`): with the cursor up for Heal, the pair's `SPELL_UPDATE_COOLDOWN` reads the slot and
/// the word both still up.
#[test]
fn a_failed_results_gcd_clear_reads_the_targeting_word_still_up() {
let mut c = client();
let heal = SpellDisplay {
id: HEAL,
name: "Heal".into(),
start_recovery_category: 133,
start_recovery_ms: 1500,
..Default::default()
};
let world = c.app.world_mut();
world.resource_mut::<crate::spell::Cooldowns>().start_gcd(
HEAL,
&heal,
std::time::Instant::now(),
&crate::spell::SpellModifiers::default(),
);
world.resource_mut::<crate::spell::SpellTargeting>().enter(
HEAL,
crate::spell::CastCommit::Spell,
0x0002,
);
c.frame(vec![]);
c.seen(&[]);
c.frame(vec![vec![SessionEvent::CastResult {
spell_id: HEAL,
success: false,
reason: Some(0x0b),
arg: None,
}]]);
let seen = c.seen(&["SPELL_UPDATE_COOLDOWN", "CURRENT_SPELL_CAST_CHANGED"]);
assert_eq!(
seen.first().map(String::as_str),
Some("SPELL_UPDATE_COOLDOWN:1:1"),
"{seen:?}"
);
assert_eq!(
seen.last().map(String::as_str),
Some("CURRENT_SPELL_CAST_CHANGED:nil:nil"),
"{seen:?}"
);
}
@@ -75,6 +75,11 @@ impl Client {
/// The two spells' rows and the potion's spell.
fn spells() -> crate::ui_action::Spells {
spells_with(&[])
}
/// [`spells`] and the rows of `more`.
fn spells_with(more: &[u32]) -> crate::ui_action::Spells {
let row = |id: u32| {
(
id,
@@ -87,11 +92,13 @@ fn spells() -> crate::ui_action::Spells {
)
};
crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([
row(FROST_NOVA),
row(BLINK),
row(POTION_SPELL),
])),
catalog: SpellCatalog::from_displays(
[FROST_NOVA, BLINK, POTION_SPELL]
.iter()
.chain(more)
.map(|&id| row(id))
.collect::<HashMap<_, _>>(),
),
..crate::ui_action::Spells::empty_for_tests()
}
}
@@ -401,3 +408,91 @@ fn a_worn_items_cooldown_reads_live_inside_its_packet() {
assert_eq!(c.eval::<f64>(live, chunk), 30.0, "live {live}");
}
}
/// The pet's list read inside the call, through the book and the bar: `GetSpellCooldown(id,
/// "pet")` queries list 1 (`0x4b40dd` → `0x4b410f` → `0x6e2ea0`), and `GetActionCooldown` on a
/// slot whose macro is bound to the pet's book likewise (`0x4e6cf7` → `0x4e6d0b`). Two pet
/// `SMSG_SPELL_COOLDOWN`s in one frame: the first packet's `SPELL_UPDATE_COOLDOWN` reads Bite cold
/// through both, the second's reads it running.
#[test]
fn the_pet_book_and_a_pet_bound_slot_read_the_pets_list_inside_their_packet() {
use benilla_protocol::messages::{PetActionEntry, ACTION_KIND_MACRO, PET_ACT_ENABLED};
const PET: u64 = 0xF130_0000_0000_0042;
const GROWL: u32 = 2649;
const BITE: u32 = 17253;
let mut c = client();
let world = c.app.world_mut();
world.insert_resource(spells_with(&[GROWL, BITE]));
let entry = |id: u32| PetActionEntry::from(id | (u32::from(PET_ACT_ENABLED) << 24));
{
let mut bar = world.resource_mut::<crate::ui_pet::PetBar>();
bar.spells.pet_guid = PET;
bar.spells.bar[0] = entry(GROWL);
bar.spells.bar[1] = entry(BITE);
bar.spells.spells = vec![entry(GROWL), entry(BITE)];
}
let pet = world
.spawn((
crate::net::Guid(PET),
Transform::default(),
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(OBJECT_TYPE, 0x9),
(22, 100),
(12, ME as u32),
(13, (ME >> 32) as u32),
])),
))
.id();
world
.resource_mut::<crate::net::GuidIndex>()
.0
.insert(PET, pet);
{
let mut slots = world.resource_mut::<crate::ui_action::ActionSlots>();
slots.buttons.insert(
3,
ActionButton {
slot: 3,
action: 1,
kind: ACTION_KIND_MACRO,
},
);
slots.dirty = true;
}
c.frame(vec![]);
c.frame(vec![]);
c.app
.world_mut()
.resource_mut::<crate::ui_macro::MacroBoundSpells>()
.0
.insert(
1,
crate::ui_macro::Bound {
spell: crate::ui_macro::BoundSpell::Spell(BITE),
pet_book: true,
},
);
c.frame(vec![]);
c.eval::<()>(
false,
r#"local g = CreateFrame("Frame")
g:RegisterEvent("SPELL_UPDATE_COOLDOWN")
g:SetScript("OnEvent", function()
local _, a = GetSpellCooldown(1, "pet")
local _, b = GetSpellCooldown(2, "pet")
local _, s = GetActionCooldown(4)
table.insert(Seen, "PET:" .. tostring(a) .. ":" .. tostring(b) .. ":" .. tostring(s))
end)"#,
);
c.seen(&[]);
let pet_cooldown = |spell: u32, ms: u32| SessionEvent::SpellCooldowns {
caster: PET,
cooldowns: vec![(spell, ms)],
};
c.frame(vec![
vec![pet_cooldown(GROWL, 3_000)],
vec![pet_cooldown(BITE, 4_000)],
]);
// The book sorts Bite ahead of Growl.
assert_eq!(c.seen(&["PET"]), ["PET:0:3:0", "PET:4:3:4"]);
}
+7 -6
View File
@@ -400,7 +400,6 @@ const HOST_HELD_UNREAD: &[&str] = &[
"::ui_loot::AutoLootByte",
"::ui_loot::LootLatch",
"::ui_merchant::MerchantOpen",
"::ui_pet::PetBar",
"::ui_petition::GuildRegistrarState",
"::ui_petition::PetitionRecords",
"::ui_session::latch::InteractLatch",
@@ -554,7 +553,6 @@ const READS_FRAME_FINAL: &[(&str, &[&str])] = &[
"spell::mods::SpellModifiers", "target::Selection", "ui_action::PlayerActions"]),
("::ui_pet::bar::feed_pet_bar", &["net::GuidIndex", "net::ObjectStore", "net::SelfGuid",
"ui_pet::PetAttackLatch", "ui_pet::PetBar"]),
("::ui_pet::bar::fire_pet_cooldown_events", &["ui_pet::PetBar"]),
// The pet's snapshot reads its store as of the drain's place (`ui_unit::views`); its name
// reads as the frame left it.
("::ui_pet::unit::feed_pet_unit", &["names::NameCache", "net::GuidIndex", "net::ObjectStore",
@@ -758,7 +756,6 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[
"::BattlefieldQueue",
"::DuelState",
"::SummonState",
"::PetBar",
"::QuestLog",
"::UiClock",
"::InteractNpc",
@@ -780,7 +777,7 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[
];
/// Every lane's queue, by type: what a handler fills there fires in the packets' order.
fn lane_types() -> [TypeId; 16] {
fn lane_types() -> [TypeId; 17] {
[
TypeId::of::<crate::ui_items::feed::BagView>(),
// The views lane: what each packet changed in the stores.
@@ -802,8 +799,10 @@ fn lane_types() -> [TypeId; 16] {
TypeId::of::<crate::target::Deaths>(),
// The item-text lane: the page-text packets' reads, stamped at their packets.
TypeId::of::<crate::ui_item_text::ItemTextLane>(),
// The cooldown lane: the player's list, which logs its writes and flushes at their places.
// The cooldown lanes: the player's list and the pet's, which log their writes and flushes
// at their places.
TypeId::of::<crate::spell::Cooldowns>(),
TypeId::of::<crate::ui_pet::PetBar>(),
// The cast views lane: the view each packet left, stamped ahead of the packet.
TypeId::of::<crate::ui_cast::CastViews>(),
]
@@ -811,7 +810,7 @@ fn lane_types() -> [TypeId; 16] {
/// [`lane_types`] by the lane that drains each: a queue is stripped only from its own lane's row,
/// so another lane's read of the same type shows as the state it is.
fn lane_queue_owners() -> [(&'static str, TypeId); 16] {
fn lane_queue_owners() -> [(&'static str, TypeId); 17] {
[
("bag view", TypeId::of::<crate::ui_items::feed::BagView>()),
("unit views", TypeId::of::<crate::ui_unit::views::ViewLog>()),
@@ -840,6 +839,7 @@ fn lane_queue_owners() -> [(&'static str, TypeId); 16] {
TypeId::of::<crate::ui_item_text::ItemTextLane>(),
),
("cooldowns", TypeId::of::<crate::spell::Cooldowns>()),
("pet cooldowns", TypeId::of::<crate::ui_pet::PetBar>()),
("cast views", TypeId::of::<crate::ui_cast::CastViews>()),
]
}
@@ -1006,6 +1006,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() {
"list rebuilds",
"loot",
"party",
"pet cooldowns",
"unit views"
]
);
+36 -14
View File
@@ -153,14 +153,16 @@ impl CooldownInfo {
/// One cooldown list, the client's `SpellHistory`: the player's is this resource (`0xcecaec`), the
/// pet's is [`crate::ui_pet::PetBar::cooldowns`] (`0xcecb04`), which the pet bar, the pet book and
/// the pet's `SMSG_SPELL_GO` leg read and write. The player's list signals at each flush site in
/// its place on the cooldown lane ([`log`]); the pet's, which keeps no log, through its counters,
/// once a frame (`ui_pet::bar::fire_pet_cooldown_events`).
/// the pet's `SMSG_SPELL_GO` leg read and write. Each signals at each flush site in its place on
/// its lane ([`log`]).
#[derive(Resource, Default)]
pub(crate) struct Cooldowns {
records: Vec<Record>,
/// The frame's writes and signals, which the drain replays (the player's list alone).
/// The frame's writes and signals, which the drain replays.
log: CooldownLog,
/// The pet's list (`0xcecb04`, the list index 1 every site tests): its sites end in the pet's
/// flush `0x4bce90` where the player's call the bags' or nothing.
pet: bool,
/// Bumped by [`Self::flush`], `0x4b31b0`. Only counts up, across [`Self::clear_silent`] too,
/// so a watcher reads a reset as no mutation.
pub(crate) generation: u64,
@@ -209,10 +211,10 @@ struct PendingItem {
}
impl Cooldowns {
/// A list that keeps no log, the pet's: its watcher reads its counters once a frame.
pub(crate) fn unlogged() -> Self {
/// The pet's list.
pub(crate) fn pet() -> Self {
Self {
log: CooldownLog::new(false),
pet: true,
..Default::default()
}
}
@@ -223,9 +225,7 @@ impl Cooldowns {
write.apply(&mut self.records);
}
/// `0x4b31b0`, the cooldown pair: `ACTIONBAR_UPDATE_COOLDOWN` and `SPELL_UPDATE_COOLDOWN`. On
/// the pet's list its watcher adds `PET_BAR_UPDATE_COOLDOWN`, the `0x4bce90` each pet site
/// calls after it.
/// `0x4b31b0`, the cooldown pair: `ACTIONBAR_UPDATE_COOLDOWN` and `SPELL_UPDATE_COOLDOWN`.
pub(crate) fn flush(&mut self) {
self.generation = self.generation.wrapping_add(1);
self.log.signalled(Signal::Pair);
@@ -236,12 +236,27 @@ impl Cooldowns {
self.log.signalled(Signal::Usable);
}
/// `0x4f93d0`, `BAG_UPDATE_COOLDOWN`, on the player's list; on the pet's, its flush
/// `0x4bce90` in its place (`0x6e3080`, `0x6e971c`).
fn flush_bags(&mut self) {
self.bag_flushes = self.bag_flushes.wrapping_add(1);
self.log.signalled(Signal::Bags);
if self.pet {
self.log.signalled(Signal::Pet);
} else {
self.bag_flushes = self.bag_flushes.wrapping_add(1);
self.log.signalled(Signal::Bags);
}
}
/// `0x4b31b0` then `0x4f93d0`, the pair every item leg calls.
/// `0x4bce90`, `PET_BAR_UPDATE_COOLDOWN`, the tail of a site that calls the bags' for neither
/// list: the pet's alone (`0x6e2e8e`, `0x6e8601`, `0x6e95b9`).
fn flush_pet(&mut self) {
if self.pet {
self.log.signalled(Signal::Pet);
}
}
/// `0x4b31b0` then `0x4f93d0`, the pair every item leg calls, and the cheat's (`0x6e9712`,
/// then `0x6e9721` or the pet's flush at `0x6e971c`).
pub(crate) fn flush_item_leg(&mut self) {
self.flush();
self.flush_bags();
@@ -249,7 +264,7 @@ impl Cooldowns {
/// `0x6e3050`'s tail after its walk, whether or not a record matched: the pair (`0x6e3071`),
/// the usable refresh (`0x6e3076`), then the bags for the player (`0x6e3089`) or the pet's flush
/// for the pet (`0x6e3080`), which reads [`Self::generation`].
/// for the pet (`0x6e3080`).
fn flush_records(&mut self) {
self.flush();
self.refresh_usable();
@@ -418,7 +433,10 @@ impl Cooldowns {
0,
0,
);
// The self leg's pair (`0x6e849d`), or the pet leg's, then its flush (`0x6e85fc`,
// `0x6e8601`).
self.flush();
self.flush_pet();
}
/// The go's item leg when the item's record names the spell (`0x6e8511`-`0x6e8566`), then
@@ -642,6 +660,8 @@ impl Cooldowns {
if spell.cooldown_on_event() {
self.refresh_usable();
}
// The pet's list's flush last (`0x6e2e8a`-`0x6e2e8e`).
self.flush_pet();
}
/// `ClearGlobalCooldown 0x6e1630`, on a failed cast: every record of the spell matches
@@ -736,6 +756,8 @@ impl Cooldowns {
}
}
self.flush();
// The pet's list's flush (`0x6e95b5`-`0x6e95b9`).
self.flush_pet();
}
/// One `SMSG_SPELL_COOLDOWN` entry, started at the packet:
+20 -3
View File
@@ -1,6 +1,6 @@
//! The cooldown getters' query at the call, the call host's half of
//! [`benilla_ui::script::CooldownQuery`]: the player's list as of the drain's place (the cooldown
//! lane replays it packet by packet, [`super::log`]), each item found as of there too
//! [`benilla_ui::script::CooldownQuery`]: the player's list and the pet's as of the drain's place
//! (their cooldown lanes replay them packet by packet, [`super::log`]), each item found as of there too
//! ([`crate::ui_unit::views::ViewStores`]), each answer derived as the feeds derive their
//! snapshots.
@@ -16,6 +16,7 @@ use crate::query_cache::Ask;
#[derive(SystemParam)]
pub(crate) struct CooldownLive<'w, 's> {
cooldowns: Option<Res<'w, Cooldowns>>,
pet: Option<Res<'w, crate::ui_pet::PetBar>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
items: Option<Res<'w, crate::items::Items>>,
/// The frame's clock pair every pushed start is mapped through, as the action feed last read
@@ -29,9 +30,24 @@ impl CooldownLive<'_, '_> {
/// `query`'s answer as of here; `None` for an item object not held then, and in a harness
/// without the list, the items or the clock.
pub(crate) fn read(&self, query: CooldownQuery) -> Option<Option<CooldownTriple>> {
let (cooldowns, items) = (self.cooldowns.as_deref()?, self.items.as_deref()?);
let (anchor, ui_now) = self.clock.as_deref()?.clock()?;
let catalog = self.spells.as_deref().map(|s| &s.catalog);
// `0x6e2ea0` on list 1 with item 0, as the pet bar's feed reads it.
if let CooldownQuery::PetSpell(spell) = query {
return Some(
self.pet
.as_deref()?
.cooldowns
.info(
spell,
0,
catalog.and_then(|c| c.get(spell)),
std::time::Instant::now(),
)
.ui_triple(anchor, ui_now),
);
}
let (cooldowns, items) = (self.cooldowns.as_deref()?, self.items.as_deref()?);
let objects = self.stores.item_objects(&self.objects);
match query {
// `0x6e2ea0`: the spell with item 0, as the bar's and the book's pushes read it.
@@ -82,6 +98,7 @@ impl CooldownLive<'_, '_> {
.flatten(),
)
}
CooldownQuery::PetSpell(_) => None,
}
}
}
+15 -35
View File
@@ -1,11 +1,11 @@
//! The player's list as each packet left it. In the reference each cooldown packet's handler
//! writes the list and signals inside itself (`0x6e9460` adds its records, then `0x4b31b0` at
//! `0x6e95b0`), and the getters query the list inside their calls (`0x6e2ea0` → `0x6e13e0`), so a
//! listener reads its own packet's records. Here every handler of the frame writes the list before
//! any listener runs, so each write is logged with its stamp, as are the flushes' signals
//! ([`Signal`]); the cooldown lane (`ui_action::cooldown_lane`) rewinds the list to the frame's
//! start and replays the log in stamp order among the other lanes, firing each signal at its
//! place. A write made during the drain, a listener's cast arming its global cooldown, lands at
//! Each list as each packet left it, the player's and the pet's. In the reference each cooldown
//! packet's handler writes its list and signals inside itself (`0x6e9460` adds its records, then
//! `0x4b31b0` at `0x6e95b0`, and for the pet's list `0x4bce90` at `0x6e95b9`), and the getters
//! query the list inside their calls (`0x6e2ea0` → `0x6e13e0`, the pet's through `0x4be03c`), so
//! a listener reads its own packet's records. Here every handler of the frame writes the lists
//! before any listener runs, so each write is logged with its stamp, as are the flushes' signals
//! ([`Signal`]); each list's lane (`ui_action::cooldown_lane`) rewinds it to the frame's start and
//! replays its log in stamp order among the other lanes, firing each signal at its place. A write made during the drain, a listener's cast arming its global cooldown, lands at
//! its own place and is not logged; its signal is, stamped under the item firing, and fires
//! straight after it.
@@ -85,6 +85,8 @@ pub(crate) enum Signal {
Usable,
/// `0x4f93d0`: `BAG_UPDATE_COOLDOWN`.
Bags,
/// `0x4bce90`: `PET_BAR_UPDATE_COOLDOWN`, the pet's list's tail in place of the bags.
Pet,
}
/// One logged item.
@@ -94,13 +96,10 @@ enum Entry {
Signal(Signal),
}
/// A list's log of the frame's writes and signals ([`super::Cooldowns`]), which only the
/// player's list keeps: the pet's list signals its own watcher once a frame
/// (`crate::ui_pet::bar`).
#[derive(Debug)]
/// A list's log of the frame's writes and signals ([`super::Cooldowns`]), the player's and the
/// pet's alike.
#[derive(Debug, Default)]
pub(crate) struct CooldownLog {
/// The list keeps the log.
on: bool,
/// The records before the log's first write.
start: Option<Vec<Record>>,
/// The writes and signals not yet replayed, in stamp order.
@@ -109,24 +108,7 @@ pub(crate) struct CooldownLog {
replaying: bool,
}
impl Default for CooldownLog {
/// The player's list's: one that keeps.
fn default() -> Self {
Self::new(true)
}
}
impl CooldownLog {
/// A log that keeps the list's writes and signals, or one that keeps nothing.
pub(super) fn new(on: bool) -> Self {
Self {
on,
start: None,
entries: VecDeque::new(),
replaying: false,
}
}
/// Insert `entry` at `at` behind everything stamped before it: one pushed while an item fires
/// is stamped under that item and goes ahead of the later ones.
fn push(&mut self, at: Stamp, entry: Entry) {
@@ -136,7 +118,7 @@ impl CooldownLog {
/// Log `write`, made on `records`, unless the drain is replaying.
pub(super) fn wrote(&mut self, records: &[Record], write: &Write) {
if !self.on || self.replaying {
if self.replaying {
return;
}
if self.start.is_none() {
@@ -147,9 +129,7 @@ impl CooldownLog {
/// Log `signal`, the replay's included: a listener's flush fires after its cause.
pub(super) fn signalled(&mut self, signal: Signal) {
if self.on {
self.push(crate::lanes::stamp(), Entry::Signal(signal));
}
self.push(crate::lanes::stamp(), Entry::Signal(signal));
}
/// The lane's head: the rewind, at the origin, while the log holds what the drain has not
@@ -397,11 +397,12 @@ fn an_awaited_record_flushes_its_running_cooldown_once_it_lands() {
/// the pair (`0x6e95b0`); `SMSG_ITEM_COOLDOWN` the pair and the bags (`0x6e9656`, `0x6e965b`);
/// the event and the clear the pair, the usable refresh and the bags (`0x6e3071`, `0x6e3076`,
/// `0x6e3089`); the cheat the pair and the bags (`0x6e9712`, `0x6e9721`); a held global cooldown
/// the pair and the refresh (`0x6e2e77`, `0x6e2e85`). The pet's list logs nothing: its watcher
/// reads its counters.
/// the pair and the refresh (`0x6e2e77`, `0x6e2e85`). The pet's list ends each site in its flush
/// `0x4bce90`, in the bags' place or after the pair and the refresh (`0x6e95b9`, `0x6e3080`,
/// `0x6e971c`, `0x6e2e8e`, the GO's pet leg `0x6e8601`).
#[test]
fn each_flush_site_logs_its_signals_in_the_references_order() {
use super::Signal::{Bags, Pair, Usable};
use super::Signal::{Bags, Pair, Pet, Usable};
let now = Instant::now();
let mods = SpellModifiers::default();
let mut cds = Cooldowns::default();
@@ -432,11 +433,35 @@ fn each_flush_site_logs_its_signals_in_the_references_order() {
site(&|c| c.start_gcd(1784, &held, now, &mods)),
[Pair, Usable]
);
let mut pet = Cooldowns::unlogged();
pet.apply_spell_cooldowns([(122, 0, Some(&nova))], &mods, CategoryPad::Skip, now);
pet.wipe();
assert!(pet.logged_signals().is_empty());
assert_eq!(pet.generation, 2, "its watcher's counter moves");
let mut pet = Cooldowns::pet();
let mut pet_site = |f: &dyn Fn(&mut Cooldowns)| {
let before = pet.logged_signals().len();
f(&mut pet);
pet.logged_signals()[before..].to_vec()
};
assert_eq!(
pet_site(&|c| c.apply_spell_cooldowns(
[(122, 0, Some(&nova))],
&mods,
CategoryPad::Skip,
now
)),
[Pair, Pet]
);
assert_eq!(
pet_site(&|c| c.cooldown_event(122, now)),
[Pair, Usable, Pet]
);
assert_eq!(pet_site(&|c| c.clear_spell(122)), [Pair, Usable, Pet]);
assert_eq!(pet_site(&|c| c.wipe()), [Pair, Pet]);
assert_eq!(
pet_site(&|c| c.start_gcd(1784, &held, now, &mods)),
[Pair, Usable, Pet]
);
assert_eq!(
pet_site(&|c| c.start_spell(122, &nova, 0, now, None)),
[Pair, Pet]
);
}
/// The drain rewinds the list to the frame's start and replays each write at its stamp: a read
+5
View File
@@ -274,6 +274,11 @@ impl CastCore<'_> {
}
match self.slot() {
Some(slot) if slot.spell_id() == spell_id => {
// The server's report pops the slot here (`0x6e2218`), after the GCD clear's pair
// its packet signalled (`0x6e167d`), which reads the cast still in flight.
if from_server {
crate::ui_cast::slot_moves_here();
}
self.cancel(true, !from_server, reason);
}
Some(_) => {}
@@ -1,51 +1,80 @@
//! The cooldown lane: the player's list replayed packet by packet, and each flush's events fired
//! at its place among the other lanes' items ([`crate::lanes`]). In the reference each cooldown
//! packet's handler writes the list and signals inside itself (`SMSG_SPELL_COOLDOWN` `0x6e9460`
//! calls `0x4b31b0` at `0x6e95b0`; `SMSG_ITEM_COOLDOWN` `0x6e95d0` calls `0x4b31b0` and `0x4f93d0`
//! at `0x6e9656` and `0x6e965b`; the event and the clear call `0x6e3050`, which signals the pair,
//! the usable refresh and the bags at `0x6e3071`, `0x6e3076` and `0x6e3089`; the cheat calls
//! `0x6e9700`), so its listeners read its own records through the getters' queries (`0x6e2ea0`,
//! `0x6e2ed0`). The list logs its writes and its signals as the handlers make them
//! ([`crate::spell::Cooldowns`]); the lane rewinds it to the frame's start, replays each write at
//! its stamp, and fires each signal there with the host lent.
//! The cooldown lanes: each list replayed packet by packet, and each flush's events fired at its
//! place among the other lanes' items ([`crate::lanes`]). In the reference each cooldown packet's
//! handler writes its list and signals inside itself (`SMSG_SPELL_COOLDOWN` `0x6e9460` calls
//! `0x4b31b0` at `0x6e95b0`, and for the pet's list `0x4bce90` at `0x6e95b9`; `SMSG_ITEM_COOLDOWN`
//! `0x6e95d0` calls `0x4b31b0` and `0x4f93d0` at `0x6e9656` and `0x6e965b`; the event and the
//! clear call `0x6e3050`, which signals the pair, the usable refresh, then the bags or the pet's
//! flush at `0x6e3071`, `0x6e3076` and `0x6e3089` or `0x6e3080`; the cheat calls `0x6e9700`, the
//! pair, then the bags or the pet's flush), so its listeners read its own records through the
//! getters' queries (`0x6e2ea0`, `0x6e2ed0`; the pet bar's `0x6e2ea0` on list 1 at `0x4be03c`).
//! Each list logs its writes and its signals as the handlers make them
//! ([`crate::spell::Cooldowns`]); its lane rewinds it to the frame's start, replays each write at
//! its stamp, and fires each signal there with the host lent. The player's list is its resource,
//! the pet's [`crate::ui_pet::PetBar::cooldowns`].
use bevy::prelude::*;
use crate::lanes::{LaneApp, Until};
use crate::spell::{Cooldowns, Signal};
use crate::ui_pet::PetBar;
/// The lane, its end and the drop under the exact complement of the drain's gate.
/// The resource that holds a cooldown list.
pub(crate) trait HoldsList: Resource {
fn list_mut(&mut self) -> &mut Cooldowns;
}
impl HoldsList for Cooldowns {
fn list_mut(&mut self) -> &mut Cooldowns {
self
}
}
impl HoldsList for PetBar {
fn list_mut(&mut self) -> &mut Cooldowns {
&mut self.cooldowns
}
}
/// The two lanes, the player's ahead of the pet's at a tie, their ends and the drops under the
/// exact complement of the drain's gate.
pub(super) fn add_cooldown_lane(app: &mut App) {
app.init_resource::<Cooldowns>()
.add_lane(
"cooldowns",
|world| world.get_resource::<Cooldowns>()?.lane_head(),
fire_cooldowns,
fire_cooldowns::<Cooldowns>,
None,
)
.add_drain_end(end_cooldowns)
.add_lane(
"pet cooldowns",
|world| world.get_resource::<PetBar>()?.cooldowns.lane_head(),
fire_cooldowns::<PetBar>,
None,
)
.add_drain_end(end_cooldowns::<Cooldowns>)
.add_drain_end(end_cooldowns::<PetBar>)
.add_systems(
Update,
drop_cooldown_log
(drop_cooldown_log::<Cooldowns>, drop_cooldown_log::<PetBar>)
.in_set(crate::ui_script::UiFeed)
.run_if(not(crate::ui_script::ingame_ui_up)),
);
}
/// The lane's firing: the list rewound to the frame's start, once per drain, at the origin and
/// A lane's firing: the list rewound to the frame's start, once per drain, at the origin and
/// alone, so every other lane's start is pushed before a signal fires; then each write the drain
/// admits replayed and each signal fired at its place with the host lent, the usable refresh
/// through the action states ([`crate::script_calls::VmWith::engine_usable_refresh`]). It stops
/// after a signal whose firing stamped anything, so what that pushed fires next.
fn fire_cooldowns(
fn fire_cooldowns<R: HoldsList>(
In(until): In<Until>,
mut vm: crate::script_calls::VmWith<ResMut<'static, Cooldowns>>,
mut vm: crate::script_calls::VmWith<ResMut<'static, R>>,
) {
if vm.own().rewind() {
if vm.own().list_mut().rewind() {
return;
}
loop {
let Some((at, signal)) = vm.own().replay(until) else {
let Some((at, signal)) = vm.own().list_mut().replay(until) else {
return;
};
let ((), moved) = crate::lanes::firing(at, || match signal {
@@ -61,6 +90,11 @@ fn fire_cooldowns(
script.fire_event("BAG_UPDATE_COOLDOWN", vec![]);
}
}),
Signal::Pet => vm.run(|script| {
if let Some(script) = script {
script.fire_event("PET_BAR_UPDATE_COOLDOWN", vec![]);
}
}),
});
if moved {
return;
@@ -69,18 +103,20 @@ fn fire_cooldowns(
}
/// The drain's end, however it ended: the list as the frame left it.
fn end_cooldowns(world: &mut World) {
if let Some(mut cooldowns) = world.get_resource_mut::<Cooldowns>() {
cooldowns.end_replay();
fn end_cooldowns<R: HoldsList>(world: &mut World) {
if let Some(mut holder) = world.get_resource_mut::<R>() {
holder.list_mut().end_replay();
}
}
/// What the packets wrote while no in-game interface is up, left unreplayed: no VM hears it.
fn drop_cooldown_log(mut cooldowns: ResMut<Cooldowns>) {
cooldowns.drop_log();
fn drop_cooldown_log<R: HoldsList>(holder: Option<ResMut<R>>) {
if let Some(mut holder) = holder {
holder.list_mut().drop_log();
}
}
/// The lane fired alone with no host, for a test that drives the list without the drain: each
/// The player's lane fired alone with no host, for a test that drives the list without the drain: each
/// signal fired on the VM, the usable refresh run on the action states directly
/// ([`super::run_watcher_refresh_once`]), then the drain's end.
#[cfg(test)]
@@ -102,5 +138,5 @@ pub(crate) fn fire_cooldown_lane(world: &mut World) {
script.fire_event("BAG_UPDATE_COOLDOWN", vec![]);
}
}
end_cooldowns(world);
end_cooldowns::<Cooldowns>(world);
}
+2 -4
View File
@@ -52,10 +52,8 @@ pub(crate) mod toggle;
mod unlearn;
mod weapon_icon;
/// Every feed that pushes cooldown triples runs `.before` this set: the pet list's flush,
/// `ui_pet::bar::fire_pet_cooldown_events`, runs `.after` it and fires the pet's events
/// synchronously, so a triple pushed later goes unread until the next cooldown change. The player's
/// list fires on the cooldown lane, after every feed ([`cooldown_lane`]).
/// Every feed that pushes cooldown triples runs `.before` this set. Both lists' flushes fire on
/// their cooldown lanes, after every feed ([`cooldown_lane`]).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CooldownEvents;
@@ -197,6 +197,7 @@ struct PetBookLeg<'a> {
fn pet_book_state(leg: PetBookLeg) -> ActionState {
let mut st = ActionState {
is_attack: leg.d.is_melee_auto_attack(),
pet_spell: Some(leg.spell),
..Default::default()
};
if let (Some(player), Some(pet)) = (leg.player, leg.pet) {
@@ -364,6 +365,12 @@ impl ActionStateMemory {
pub(crate) fn clock(&self) -> Option<(Instant, f64)> {
self.clock
}
/// The pair a feed would have read, for a rig that runs no action feed.
#[cfg(test)]
pub(crate) fn set_clock(&mut self, clock: (Instant, f64)) {
self.clock = Some(clock);
}
}
/// One pass over the slots: when it runs, and which of the refreshes it is.
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::ui_action::{CastFail, CastFailLines, MessageSink, Shown, Spells};
use crate::ui_unit::UnitFeed;
mod views;
pub(crate) use views::CastViews;
pub(crate) use views::{slot_moves_here, CastViews};
/// One edge of our own cast, queued by the spell's packet handlers, its reports and its cancels.
pub(crate) enum CastBarEdge {
+48 -9
View File
@@ -134,6 +134,22 @@ impl CastViews {
self.shown = Some(view);
}
/// `view` logged at `at`, when it differs from the one before it.
fn log(&mut self, at: Stamp, view: CastView) {
let Some(last) = self.last else {
return;
};
if last == view {
return;
}
if self.entries.is_empty() && !self.started && self.start.is_none() {
self.start = Some(last);
}
let i = self.entries.partition_point(|(s, _)| *s < at);
self.entries.insert(i, (at, view));
self.last = Some(view);
}
/// A logged `view` into the VM, with the fields the drain's calls moved as they stand live.
fn replay(&mut self, script: &mut UiScript, view: CastView) {
let view = self.called.over(view, self.live.unwrap_or(view));
@@ -159,9 +175,25 @@ fn live_view(world: &mut World) -> Option<CastView> {
view
}
thread_local! {
/// Where the packet being handled moves the in-flight slot, past what it raised before
/// ([`slot_moves_here`]).
static SLOT_MOVES: std::cell::Cell<Option<Stamp>> = const { std::cell::Cell::new(None) };
}
/// The packet being handled moves the in-flight slot here, after what its handler raised so far:
/// a failed `SMSG_CAST_RESULT`'s report fires the GCD clear's pair (`0x6e1d89` → `0x6e167d`)
/// before it pops the slot (`0x6e2218`), so that pair's listeners read the cast still in flight.
/// The packet's view then lands in two steps: everything but the slot and the targeting word,
/// which `CancelCast 0x6e4940` drops with it (`0x6e4955`), ahead of the packet, and those here.
pub(crate) fn slot_moves_here() {
SLOT_MOVES.set(Some(crate::lanes::stamp()));
}
/// The net stage's start: the view as the frame's input left it, which the first packet that
/// moves it is compared with.
fn packets_begin(world: &mut World) {
SLOT_MOVES.set(None);
let view = live_view(world);
if let Some(mut views) = world.get_resource_mut::<CastViews>() {
views.last = view;
@@ -169,8 +201,10 @@ fn packets_begin(world: &mut World) {
}
/// A packet handled: its view logged at `at`, the stamp taken ahead of its handler, when it
/// differs from the one before it.
/// differs from the one before it; with its slot at the stamp its handler moved it, when it
/// named one ([`slot_moves_here`]).
fn packet_end(world: &mut World, at: Stamp) {
let moves = SLOT_MOVES.take().filter(|s| *s > at);
let Some(view) = live_view(world) else {
return;
};
@@ -180,15 +214,20 @@ fn packet_end(world: &mut World, at: Stamp) {
let Some(last) = views.last else {
return;
};
if last == view {
return;
if let Some(moves) = moves {
// `CancelCast 0x6e4940` drops the targeting word (`0x6e4955`) and pops the slot
// (`0x6e49d6`) at the pop's place.
let ahead = CastView {
slot: last.slot,
item: last.item,
targeting: last.targeting,
..view
};
views.log(at, ahead);
views.log(moves, view);
} else {
views.log(at, view);
}
if views.entries.is_empty() && !views.started && views.start.is_none() {
views.start = Some(last);
}
let i = views.entries.partition_point(|(s, _)| *s < at);
views.entries.insert(i, (at, view));
views.last = Some(view);
}
/// The lane's head: its start, at the origin, while the frame logged a view; then its next entry.
+3 -51
View File
@@ -24,7 +24,7 @@ pub(super) struct PetBarMemory {
slots: Vec<PetActionView>,
state: Option<u32>,
/// The ten cooldown triples as last pushed, so a cooldown alone is pushed with no event: its
/// events are [`fire_pet_cooldown_events`]'s.
/// events are the list's flush, on the pet's cooldown lane (`crate::ui_action::cooldown_lane`).
cooldowns: Vec<Option<(i64, u32, bool)>>,
}
@@ -222,8 +222,8 @@ pub(crate) fn active_aura_press(
}
/// Push the ten slot views on a change, and `PET_BAR_UPDATE` when the bar itself moved. A change
/// of the cooldowns alone is pushed without an event: the pet's cooldown events fire off the
/// list's generation, so a natural expiry fires none, as in the reference.
/// of the cooldowns alone is pushed without an event: the pet's cooldown events are its list's
/// flushes, so a natural expiry fires none, as in the reference.
pub(super) fn feed_pet_bar(
mut vm: crate::script_calls::VmWith<(
Res<'static, PetBar>,
@@ -362,51 +362,3 @@ pub(super) fn feed_pet_bar(
}
})
}
/// The flush behind every mutation of the pet's cooldown list: `0x4b31b0` fires
/// `ACTIONBAR_UPDATE_COOLDOWN` (`0x4e5c60`, event `0xd8`) then `SPELL_UPDATE_COOLDOWN` (`0x106`),
/// and `0x4bce90` fires `PET_BAR_UPDATE_COOLDOWN` (`0x162`). Its callers are the pet's
/// `SMSG_SPELL_GO` leg (`0x6e85fc`), `StartGlobalCooldown` for the list (`0x6e2e77`, `0x6e2e8e`),
/// `SMSG_SPELL_COOLDOWN` for the pet's guid (`0x6e95b0`, `0x6e95b9`), `SMSG_COOLDOWN_EVENT` and
/// `SMSG_CLEAR_COOLDOWN` (`0x6e3071`, `0x6e3080`) and `SMSG_COOLDOWN_CHEAT` (`0x6e9712`,
/// `0x6e971c`); each is a change of [`Cooldowns::generation`](crate::spell::Cooldowns), and one
/// frame's changes fire it once. Between the two, the list's own usable refresh `0x4b31c0`
/// (`0x6e2e85`, `0x6e3076`) signals `SPELL_UPDATE_USABLE` on a change of
/// [`Cooldowns::usable_refreshes`](crate::spell::Cooldowns). `SMSG_PET_SPELLS` seeds without it,
/// so the events never fire for a rebuild. Runs after
/// [`CooldownEvents`](crate::ui_action::CooldownEvents): every feed that pushes a pet cooldown,
/// the bar's and the book's, has pushed by then, so the handlers read the new list.
pub(super) fn fire_pet_cooldown_events(
mut vm: crate::script_calls::VmWith<Res<'static, PetBar>>,
mut memory: Local<
crate::ui_script::VmMemo<(crate::ui_script::gate::Watch, crate::ui_script::gate::Watch)>,
>,
) {
let (generation, usable_refreshes) = {
let bar = vm.own();
(bar.cooldowns.generation, bar.cooldowns.usable_refreshes)
};
vm.run(|script| {
let Some(script) = script else {
return;
};
// A new VM starts from the list as it stands: the reference flushes on a mutation, not at
// load.
let ((flushes, refreshes), fresh) = memory.get_reset(script);
let flushed = flushes.moved(generation);
let refreshed = refreshes.moved(usable_refreshes);
if fresh || !(flushed || refreshed) {
return;
}
if flushed {
script.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
script.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
}
if refreshed {
script.fire_event("SPELL_UPDATE_USABLE", vec![]);
}
if flushed {
script.fire_event("PET_BAR_UPDATE_COOLDOWN", vec![]);
}
})
}
+2 -3
View File
@@ -358,9 +358,8 @@ impl PetPress<'_, '_> {
/// The spell arm's tail (`0x4bd367`-`0x4bd36e`) for a press past the usability gate whose
/// `Spell.dbc` record resolved and that neither cancels an aura nor takes the attempt: it calls
/// `StartGlobalCooldown 0x6e2de0(spellId, 1)` before the send (`0x4bd444`), the spell's own
/// `StartRecovery*` pair, under op 21, into the pet's list (`0xcecaec + 0x18`). The insert moves
/// the list's generation, which carries the flush that follows it (`0x6e2e77`, `0x6e2e8e`) to
/// `bar::fire_pet_cooldown_events`.
/// `StartRecovery*` pair, under op 21, into the pet's list (`0xcecaec + 0x18`), and the flush
/// that follows it (`0x6e2e77`, `0x6e2e8e`), at its place on the pet's cooldown lane.
///
/// The pet's GCD starts in three places, and this arm is one. A `SMSG_SPELL_COOLDOWN` addressed
/// to the pet's guid inserts the same pair for every entry whose spell lacks `Attributes` bit 25
+52 -60
View File
@@ -232,20 +232,62 @@ fn the_pets_event_and_clear_refresh_the_usable_state_inside_the_flush() {
assert_eq!(rig.frame(), FLUSH, "the cheat on an empty list");
}
/// The frame's mutations are one flush for the frame, as the player's list has them.
/// Each packet's handler flushes inside itself (`0x6e95b0`, `0x6e95b9`): two pet cooldown packets
/// in one frame fire the flush twice, and each `PET_BAR_UPDATE_COOLDOWN` listener's
/// `GetPetActionCooldown` (`0x4be03c` → `0x6e2ea0` on the pet's list) reads the list as its own
/// packet left it: Claw cold to the first, running to the second.
#[test]
fn several_mutations_in_a_frame_fire_the_flush_once() {
fn each_pet_cooldown_packet_flushes_and_reads_its_own_list() {
let mut rig = rig(
vec![(GROWL, growl()), (CLAW, claw())],
&[GROWL, CLAW],
Some(summoned_pet()),
);
ui(&mut rig)
.run(
"READ = {} local f = CreateFrame('Frame') f:RegisterEvent('PET_BAR_UPDATE_COOLDOWN') \
f:SetScript('OnEvent', function() local _, a = GetPetActionCooldown(1) \
local _, b = GetPetActionCooldown(2) \
table.insert(READ, tostring(a) .. ':' .. tostring(b)) end)",
)
.unwrap();
rig.receive(vec![
SessionEvent::SpellCooldowns {
caster: PET,
cooldowns: vec![(GROWL, 3000)],
},
SessionEvent::SpellCooldowns {
caster: PET,
cooldowns: vec![(CLAW, 4000)],
},
]);
assert_eq!(rig.frame(), [FLUSH, FLUSH].concat());
assert_eq!(
ui(&mut rig).eval::<Vec<String>>("return READ").unwrap(),
["3:0", "3:4"]
);
}
/// The pet's flush falls in packet order with the player's: a player's `SMSG_SPELL_COOLDOWN`
/// ahead of the pet's signals its pair first, then the pet's packet its pair and its flush.
#[test]
fn the_pets_flush_falls_in_packet_order_with_the_players() {
let mut rig = growl_rig();
rig.receive(vec![
go(PET, GROWL),
SessionEvent::SpellCooldowns {
caster: ME,
cooldowns: vec![(GROWL, 3000)],
},
SessionEvent::SpellCooldowns {
caster: PET,
cooldowns: vec![(GROWL, 3000)],
},
]);
assert_eq!(rig.frame(), FLUSH);
assert_eq!(rig.frame(), [&FLUSH[..2], &FLUSH[..]].concat());
}
/// `SMSG_PET_SPELLS` seeds the list through `0x6e2c60`, which flushes nothing, and ends in
@@ -350,60 +392,6 @@ fn a_new_vm_is_not_flushed_for_a_list_that_already_moved() {
assert_eq!(rig.frame(), FLUSH, "the next mutation is a flush");
}
/// The flush reads what the feeds pushed: it runs after the bar's feed and the book's, which
/// push the pet's cooldown triples the handlers ask for, and (declared through the cooldown
/// events) after the player's own flush. Skips where bevy carries no system names.
#[test]
fn the_flush_runs_after_every_feed_that_pushes_a_pet_cooldown() {
use crate::game_plugins::schedule_tests::{census, headless_client, SyncPoints};
use bevy::prelude::Update;
use std::collections::{HashMap, HashSet};
let mut app = headless_client();
let c = census(&mut app, Update, SyncPoints::Declared);
let named = |needle: &str| {
let hits: Vec<_> = c
.systems
.iter()
.filter(|(_, s)| s.name.ends_with(needle))
.map(|(k, _)| *k)
.collect();
(!hits.is_empty()).then(|| {
assert_eq!(hits.len(), 1, "one system named {needle}");
hits[0]
})
};
let Some(fire) = named("::fire_pet_cooldown_events") else {
eprintln!("skipped: this build carries no system names");
return;
};
let mut after: HashMap<_, Vec<_>> = HashMap::new();
for (a, b) in &c.dependencies {
after.entry(*a).or_default().push(*b);
}
let runs_before = |first| {
let (mut seen, mut stack) = (HashSet::new(), vec![first]);
while let Some(n) = stack.pop() {
if n == fire {
return true;
}
if seen.insert(n) {
stack.extend(after.get(&n).into_iter().flatten().copied());
}
}
false
};
for feed in [
"::feed_pet_bar",
"::feed_pet_book",
"::feed_action_state",
"::fire_action_events",
] {
let key = named(feed).unwrap_or_else(|| panic!("{feed} is scheduled"));
assert!(runs_before(key), "{feed} runs before the pet's flush");
}
}
/// The stock spellbook (`SpellBookFrame.lua:209`-`231`): a `SpellButton` redraws on
/// `SPELL_UPDATE_COOLDOWN`, and on `PET_BAR_UPDATE` while the pet tab is open, and never hears
/// `PET_BAR_UPDATE_COOLDOWN`. So the GCD a pet-bar press starts sweeps the pet tab's button
@@ -567,7 +555,7 @@ fn an_inserts_prune_fires_only_the_inserts_own_flush() {
for _ in 0..5 {
rig.press(1);
}
assert_eq!(rig.frame(), FLUSH, "the presses' own flush");
assert_eq!(rig.frame(), FLUSH.repeat(5), "each press's own flush");
let held = rig.generation();
let_the_gcds_run_out();
let pressed = Instant::now();
@@ -611,7 +599,11 @@ fn an_insert_changes_no_bar_read_of_the_records_that_stay() {
for _ in 0..5 {
rig.press(1);
}
assert_eq!(rig.frame(), FLUSH);
assert_eq!(
rig.frame(),
FLUSH.repeat(7),
"the go, the packet and each press"
);
let (running, parked_read) = (rig.cooldown(2), rig.cooldown(3));
assert_eq!(running.1, 5.0, "Growl sweeps its 5 s");
assert!(
+6 -10
View File
@@ -25,7 +25,7 @@ mod usable;
// The predicate `0x4bcf70` over what the caller already holds, for the action bar's pet legs.
pub(crate) use usable::actions_usable as pet_actions_usable;
use bar::{feed_pet_bar, fire_pet_cooldown_events};
use bar::feed_pet_bar;
// A pet bar press and the pet's other verbs, applied in call order by `crate::script_calls`.
pub(crate) use drain::call_pet_off;
pub(crate) use drain::{cast_pet_press, PetPress, PetStep};
@@ -60,8 +60,8 @@ pub(crate) struct PetBar {
pub(crate) spells: PetSpells,
/// The pet's own cooldowns, the list at `0xcecb04`. Seeded from `SMSG_PET_SPELLS`, topped up
/// by `SMSG_SPELL_COOLDOWN` for the pet's guid, by the pet's own `SMSG_SPELL_GO` and by a
/// press's GCD. Each mutation but the seed fires the flush, off [`Cooldowns::generation`]
/// (`bar::fire_pet_cooldown_events`), once a frame: it keeps no log.
/// press's GCD. Each mutation but the seed fires the flush at its place, on the pet's cooldown
/// lane (`crate::ui_action::cooldown_lane`).
pub(crate) cooldowns: Cooldowns,
/// `PET_BAR_UPDATE` signals, counted. Both state writes signal unconditionally (`0x4bc940`,
/// `0x4bc960`), and `OnClick`'s `SetChecked(0)` needs that repaint to relight a press on the
@@ -94,11 +94,11 @@ impl PetAttackLatch {
}
impl Default for PetBar {
/// No bar, and the pet's list, which keeps no log ([`Cooldowns::unlogged`]).
/// No bar, and the pet's list ([`Cooldowns::pet`]).
fn default() -> Self {
Self {
spells: PetSpells::default(),
cooldowns: Cooldowns::unlogged(),
cooldowns: Cooldowns::pet(),
bar_signals: 0,
book_sorts: 0,
expires: None,
@@ -177,15 +177,11 @@ impl Plugin for UiPetPlugin {
.before(feed_pet_bar),
// After the pet snapshot: these feeds' events reach Lua at once, and their
// handlers read `HasPetUI()`, which `crate::ui_pet_stats` pushes. The bar pushes
// its cooldown triples before the cooldown events and the pet's flush fires after
// them, so the buttons it wakes read the new list.
// its cooldown triples before the cooldown events, as every cooldown feed does.
feed_pet_bar
.in_set(UnitFeed)
.after(crate::ui_pet_stats::PetSnapshot)
.before(CooldownEvents),
fire_pet_cooldown_events
.in_set(UnitFeed)
.after(CooldownEvents),
feed_pet_unit
.in_set(UnitFeed)
.in_set(crate::ui_unit::field_events::FieldEventPushes)
+12 -3
View File
@@ -20,7 +20,7 @@ use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfG
use crate::spell::Cooldowns;
use crate::ui_action::Spells;
use super::bar::{feed_pet_bar, fire_pet_cooldown_events};
use super::bar::feed_pet_bar;
use super::drain::PetPress;
use super::PetBar;
@@ -178,14 +178,14 @@ pub(super) fn rig(
}
world.insert_non_send_resource(listening_vm());
world.init_resource::<super::PetBarFeed>();
// The order the plugin gives them: the feeds that push a pet cooldown, then the flush.
// The order the plugin gives them: the feeds that push a pet cooldown; the flush is the pet's
// cooldown lane's, in the drain.
let mut feed = Schedule::default();
feed.add_systems(
(
super::drain::arm_owed_pet_gcds,
crate::ui_pet_book::feed_pet_book,
feed_pet_bar,
fire_pet_cooldown_events,
)
.chain(),
);
@@ -204,6 +204,15 @@ impl Rig {
/// The bar's feeds for one frame, then the lanes' drain, and the events they fired.
pub(super) fn frame(&mut self) -> Vec<String> {
self.feed.run(self.app.world_mut());
// The frame's clock pair, as the action feed hands it to the call host's cooldown reads.
let clock = {
let c = self.app.world().resource::<crate::ui_script::UiClock>();
(c.anchor, c.ui_now)
};
self.app
.world_mut()
.resource_mut::<crate::ui_action::ActionStateMemory>()
.set_clock(clock);
crate::lanes::fire_in_order(self.app.world_mut());
let mut script = self
.app
+2 -2
View File
@@ -112,8 +112,8 @@ pub(crate) fn feed_pet_book(
// The push signals nothing: the book's `SPELLS_CHANGED` is its packet's sort
// (`0x4bdadb` → `0x4b2fd0`, `crate::ui_pet`'s handler), on the notice lane after this
// push. A cooldown repaints the pet page through the flush's `SPELL_UPDATE_COOLDOWN`
// (`SpellBookFrame.lua:209`, `221`), which `ui_pet`'s `fire_pet_cooldown_events` fires
// after this feed's push; autocast, through `PET_BAR_UPDATE` (`227-231`).
// (`SpellBookFrame.lua:209`, `221`), which the pet's cooldown lane fires after every
// feed; autocast, through `PET_BAR_UPDATE` (`227-231`).
}
})
}
+11 -6
View File
@@ -101,6 +101,9 @@ pub struct ActionState {
/// The entry an item slot holds, which `IsCurrentAction`'s item arm compares with the in-flight
/// cast's item (`0x4e53fc`-`0x4e5428`); `None` for any other slot.
pub item: Option<u32>,
/// The pet's book spell a macro slot is bound to (`0x4e5a50`'s pet flag), whose cooldown is
/// queried on the pet's list (`0x4e6d04` → `0x6e2ea0`); `None` for any other slot.
pub pet_spell: Option<u32>,
/// `(start_ms, duration_ms, enabled)`, the start absolute on the `GetTime` clock, so a running
/// cooldown re-pushes the same triple and a re-arm a new one (`0x6e13e0` returns the start).
pub cooldown: Option<(i64, u32, bool)>,
@@ -649,8 +652,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// a slot that reaches no query, an empty one included, answers `(0, 0, 0)`, and one that
// reaches it with nothing running `(0, 0, 1)`, so a later `CooldownFrame_SetTimer` replays no
// sweep and no finish flash. The query reads the list inside the call (`0x4e6ca0`: `0x6e2ea0`
// for a spell at `0x4e6d0b`, `0x6e2ed0` for an item at `0x4e6ce9`), through the lent host; a slot bound to the
// pet's book reads the pet's list off the snapshot.
// for a spell at `0x4e6d0b`, `0x6e2ed0` for an item at `0x4e6ce9`), through the lent host; a
// slot bound to the pet's book queries the pet's list, the resolve's flag in `edx`
// (`0x4e6cf7` → `0x4e6d04`).
g.set(
"GetActionCooldown",
lua.create_function(|lua, action: Value| {
@@ -666,10 +670,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
else {
return Ok((0.0, 0.0, 0));
};
let query = match (state.state.spell, state.state.item) {
(Some(spell), _) => Some(super::CooldownQuery::Spell(spell)),
(None, Some(entry)) => Some(super::CooldownQuery::Item(entry)),
(None, None) => None,
let query = match (state.state.spell, state.state.item, state.state.pet_spell) {
(Some(spell), _, _) => Some(super::CooldownQuery::Spell(spell)),
(None, Some(entry), _) => Some(super::CooldownQuery::Item(entry)),
(None, None, Some(spell)) => Some(super::CooldownQuery::PetSpell(spell)),
(None, None, None) => None,
};
(query, state.cooldown)
};
@@ -1,13 +1,14 @@
//! The cooldown getters' query of the player's list inside the call. `GetActionCooldown`
//! (`0x4e6ed0` → `0x4e6ca0`), `GetSpellCooldown` (`0x4b40a0`), `GetContainerItemCooldown`
//! (`0x4f99b0`) and `GetInventoryItemCooldown` (`0x4c8a60`) ask the list as it stands
//! (`GetSpellCooldown 0x6e2ea0` and `GetItemCooldown 0x6e2ed0`, both through `GetCooldownInfo
//! 0x6e13e0`), so a listener reads its own packet's records. Through the lent host when one is
//! The cooldown getters' query of a list inside the call. `GetActionCooldown` (`0x4e6ed0` →
//! `0x4e6ca0`), `GetSpellCooldown` (`0x4b40a0`), `GetContainerItemCooldown` (`0x4f99b0`) and
//! `GetInventoryItemCooldown` (`0x4c8a60`) ask the player's list as it stands, and
//! `GetPetActionCooldown` (`0x4bdfa0`) the pet's (`GetSpellCooldown 0x6e2ea0` and `GetItemCooldown
//! 0x6e2ed0`, both through `GetCooldownInfo 0x6e13e0`), so a listener reads its own packet's
//! records. Through the lent host when one is
//! free ([`crate::script::CallHost::read_cooldown`]); with none, the frame's snapshot answers.
use mlua::Lua;
/// What a getter asks of the player's list.
/// What a getter asks of a list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CooldownQuery {
/// `0x6e2ea0` on the player's list: the spell, with item 0.
@@ -21,6 +22,8 @@ pub enum CooldownQuery {
/// `0x6e2ed0` for the item the active player wears in descriptor slot `slot0`, the paper
/// doll's (`0x4c8b3a`), likewise.
Worn(u8),
/// `0x6e2ea0` on the pet's list (`edx = 1`, `0x4be037`): a pet bar slot's spell.
PetSpell(u32),
}
/// `(start_ms on the GetTime clock, duration_ms, enabled)`, the shape the snapshots push.
+14 -2
View File
@@ -504,8 +504,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `is-number` else `Usage: GetPetActionCooldown(index)` (`0x8478d8`, `0x4bdfb7`).
let i = binding_abi::index_arg32(lua, i, "Usage: GetPetActionCooldown(index)")?;
let now = crate::script::clock::now(lua);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
let (spell, snapshot) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = slot_at(&model, i);
(
slot.and_then(|s| s.view.spell_id),
slot.and_then(|s| s.cooldown),
)
};
// A spell slot's query reads the pet's list inside the call (`0x4be03c` →
// `0x6e2ea0`), through the lent host; with none, the frame's snapshot answers.
let live = spell.and_then(|spell| {
super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell))
});
Ok(match live.unwrap_or(snapshot) {
Some((start, duration, enabled)) if start + duration > now || !enabled => (
crate::script::clock::held_start(start, enabled, now),
duration,
+13 -11
View File
@@ -454,28 +454,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetSpellCooldown(id, bookType) -> start, duration, enable in `GetTime` seconds, as
// `GetActionCooldown`: enable 0 for an on-hold cooldown; an expired or empty one answers the
// reference's no-cooldown `(0, 0, 1)`, so a re-feed cannot replay the finish flash. A player's
// book slot queries the list inside the call (`0x4b410f` → `0x6e2ea0`), through the lent host;
// the pet's book reads the pet's list off the snapshot.
// reference's no-cooldown `(0, 0, 1)`, so a re-feed cannot replay the finish flash. The slot
// queries its book's list inside the call (`0x4b410f` → `0x6e2ea0`, the book type in `edx`
// from `0x4b40dd`), through the lent host.
g.set(
"GetSpellCooldown",
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellCooldown")?;
let now = crate::script::clock::now(lua);
let (spell, snapshot) = {
let (query, snapshot) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = book_slot(&model, id, &book_type);
let spell = slot
.filter(|_| !is_pet_book(&book_type))
.map(|s| s.spell_id);
let query = slot.map(|s| {
if is_pet_book(&book_type) {
super::CooldownQuery::PetSpell(s.spell_id)
} else {
super::CooldownQuery::Spell(s.spell_id)
}
});
let snapshot = slot
.and_then(|s| s.cooldown)
.map(super::cooldown_read::seconds);
(spell, snapshot)
(query, snapshot)
};
let live = spell.and_then(|spell| {
super::cooldown_read::read_live(lua, super::CooldownQuery::Spell(spell))
});
let live = query.and_then(|q| super::cooldown_read::read_live(lua, q));
Ok(match live.unwrap_or(snapshot) {
Some((start, duration, enabled)) if start + duration > now || !enabled => (
crate::script::clock::held_start(start, enabled, now),
@@ -30,6 +30,7 @@ fn action_state_bindings_answer_the_reference_conventions() {
is_attack: false,
equipped: false,
item: None,
pet_spell: None,
// 4 s remaining of a 10 s cooldown, running: started at GetTime 94.
cooldown: Some((94_000, 10_000, true)),
no_cooldown_query: false,
+1 -1
View File
@@ -700,7 +700,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
#### script
- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:414`](../crates/benilla-ui/src/script/action.rs#L414))
- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:417`](../crates/benilla-ui/src/script/action.rs#L417))
- The files the handler writes beside the install (`<Name>.pub` with the key, `<Name>.url`; `0x51dce0`, `0x51dde0`) are not written, as the install is read-only; the key is never read either, as there is no signature to verify with it. ([`src/script/addon.rs:207`](../crates/benilla-ui/src/script/addon.rs#L207))
- The reference fails silently; we report it, as a player needs to know, and hold the file so the shutdown write keeps it. ([`src/script/addon.rs:785`](../crates/benilla-ui/src/script/addon.rs#L785))
- The reference drops a send that resolves to PARTY while party slot 0's guid (`0xbc6f48`) is zero (`0x49fa9f`-`0x49faab`); benilla sends it, because `PartyState` is empty until the first `SMSG_GROUP_LIST` and the gate would swallow a real broadcast. vmangos drops a group-less party line anyway (`ChatHandler.cpp:476`). ([`src/script/addon_message.rs:7`](../crates/benilla-ui/src/script/addon_message.rs#L7))