From 24cd215ba6903efdb2a85b59ecbad479e7bf2477 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:06:35 +0700 Subject: [PATCH] The cast getters read live; a refused reagent line shows at its answer (#2818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cast state's getters read a copy a feed pushed once a frame, and a refused cast line that missed the item cache retried at frame time, so both lagged their packets. **Reference** - IsCurrentAction, IsAutoRepeatAction, SpellIsTargeting, SpellStopCasting and the targeting word's checks read the cast state at the call (`0x4e54c1`, `0x6e48e0`, `0xcecac0`). - A reagent or totem refusal that misses the cache registers `ShowItemCastFailure` (`0x6e1e82`-`0x6e1ea4`, `0x6e29b0`). The item's answer shows it, with UNKNOWN on a refusal (`0x6e2a49`). With no item word no line ever shows (`0x55be5d`). **Changed** - The getters read through the host, and `feed_cast_reports` is gone. - The refused line rides the item cache's callback list. - The "Missing reagent" fallback that 1.12.1 never shows is removed. **Tests:** the line between its neighbours for a record and for a refusal, the getters between packets, no line with no item word. Part of #2168 and #2217. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/items.rs | 14 +- .../benilla-app/src/lanes/cast_live_tests.rs | 227 ++++++++++++++++++ crates/benilla-app/src/lanes/guard_tests.rs | 24 +- .../benilla-app/src/lanes/host_read_tests.rs | 12 +- crates/benilla-app/src/lanes/tests.rs | 9 +- crates/benilla-app/src/net/objects.rs | 8 + crates/benilla-app/src/script_calls.rs | 7 +- crates/benilla-app/src/script_calls/live.rs | 3 + crates/benilla-app/src/spell/cast_send.rs | 4 +- crates/benilla-app/src/spell/inflight.rs | 46 ++-- crates/benilla-app/src/spell/net.rs | 3 +- crates/benilla-app/src/spell/report.rs | 1 - crates/benilla-app/src/ui_action/cast_fail.rs | 10 + .../benilla-app/src/ui_action/cast_lines.rs | 91 +++---- crates/benilla-app/src/ui_action/errors.rs | 7 +- .../benilla-app/src/ui_action/feed_tests.rs | 5 +- crates/benilla-app/src/ui_action/mod.rs | 3 +- crates/benilla-app/src/ui_cast.rs | 78 +----- crates/benilla-app/src/ui_cast/views.rs | 70 ++++-- crates/benilla-app/src/ui_items/mod.rs | 7 +- crates/benilla-ui/src/script/action.rs | 55 ++++- crates/benilla-ui/src/script/container.rs | 2 +- crates/benilla-ui/src/script/cursor.rs | 7 +- .../benilla-ui/src/script/inventory_verbs.rs | 4 +- crates/benilla-ui/src/script/merchant.rs | 14 +- crates/benilla-ui/src/script/minimap.rs | 4 +- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/spellbook.rs | 22 +- crates/benilla-ui/src/script/unit/bindings.rs | 6 +- docs/DEVIATIONS.md | 12 +- 30 files changed, 483 insertions(+), 274 deletions(-) diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index 5b80b9dd5..587289a82 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -613,6 +613,10 @@ pub(crate) enum RecordCallback { /// The action bar's, asked by an item slot's icon, count and spell lookups (`0x4e6b10`, /// `0x4e6d69`, `0x4e5b30` with `0x4e5b80`), deduped. ActionSlots, + /// A refused cast's reagent or tool line whose item was not cached (`0x6e1ea4` with + /// `ShowItemCastFailure 0x6e29b0` and the reason), one per refusal; the spell picks the + /// line's message as its first pass would. + CastFailure { spell: u32, reason: u8 }, } impl Items { @@ -864,9 +868,8 @@ impl Items { self.templates.answered(entry) } - /// Whether the server refused `entry`, not still pending; the cast-fail redisplay then shows - /// the reference's `"UNKNOWN"` instead of waiting (`0x6e29b0` runs on the status-0 callback and - /// finds no record, `0x6e2a49`). + /// Whether the server refused `entry` and nothing has asked since, not still pending: the + /// status-0 answer its waiters were told. pub(crate) fn template_answered_unknown(&self, entry: u32) -> bool { self.templates.answered_unknown(entry) } @@ -884,7 +887,7 @@ impl Items { self.templates.insert(entry, info); } // A refusal moves the generation too: "still asking" to "unknown" is a display - // transition for the cast-fail redisplay. + // transition. None => { if self.templates.refuse(entry) == crate::query_cache::Refused::Record { self.note_change(entry, false); @@ -939,8 +942,7 @@ impl Items { self.templates.clear(); } - /// Bumped by every landed answer, for consumers besides the drain; the stand-in for the - /// reference's `DBCACHECALLBACK` redisplay (`0x6e29b0`). + /// Bumped by every landed answer, for consumers besides the drain. pub(crate) fn template_epoch(&self) -> u64 { self.templates.generation() } diff --git a/crates/benilla-app/src/lanes/cast_live_tests.rs b/crates/benilla-app/src/lanes/cast_live_tests.rs index e94652e94..e9ecedadc 100644 --- a/crates/benilla-app/src/lanes/cast_live_tests.rs +++ b/crates/benilla-app/src/lanes/cast_live_tests.rs @@ -459,3 +459,230 @@ fn a_failed_results_gcd_clear_reads_the_targeting_word_still_up() { "{seen:?}" ); } + +/// A reagent the refusal below names, whose record the item cache lacks until its answer. +const SHARD: u32 = 6265; + +/// The reagent line's strings, and a spy on the lines, each `UI_ERROR_MESSAGE` as `LINE:`. +fn spy_lines(c: &mut Client) { + c.eval::<()>( + false, + r#"SPELL_FAILED_REAGENTS = "Missing reagent: %s" + ERR_SPELL_FAILED_REAGENTS = "%s" + local g = CreateFrame("Frame") + g:RegisterEvent("UI_ERROR_MESSAGE") + g:SetScript("OnEvent", function() table.insert(Seen, "LINE:" .. arg1) end)"#, + ); +} + +/// Our cast of `spell_id` refused for a missing reagent, its item word `arg`. +fn refuse_for_a_reagent(c: &mut Client, spell_id: u32, arg: Option) { + c.frame(vec![vec![SessionEvent::CastResult { + spell_id, + success: false, + reason: Some(0x5c), + arg, + }]]); +} + +/// [`client`] with [`spy_lines`]; then our cast refused for [`SHARD`], which the cache lacks: the +/// reporter's lookup asks for it and leaves past the line and its log line (`0x6e1ea4`, +/// `0x6e1eab`). +fn refused_for_a_reagent() -> Client { + let mut c = client(); + spy_lines(&mut c); + refuse_for_a_reagent(&mut c, HEAL, Some(SHARD)); + assert!( + c.seen(&["LINE"]).is_empty(), + "no line while the record is asked" + ); + c +} + +/// The answer for [`SHARD`]: its record, or the server's refusal. +fn shard_answer(name: Option<&str>) -> SessionEvent { + SessionEvent::ItemTemplate { + entry: SHARD, + info: name.map(|n| Box::new(crate::items::test_template(n))), + } +} + +/// The record's answer runs the refusal's callback (`ShowItemCastFailure 0x6e29b0`) inside its +/// packet (`ItemCacheReceive 0x55bdb0`): the line fires between the power packets around it. +#[test] +fn a_reagent_line_shows_at_its_records_answer() { + let mut c = refused_for_a_reagent(); + c.frame(vec![ + vec![values(ME, &[(MANA, 90)])], + vec![shard_answer(Some("Soul Shard"))], + vec![values(ME, &[(MANA, 80)])], + ]); + assert_eq!( + c.seen(&["UNIT_MANA", "LINE"]), + [ + "UNIT_MANA:nil:nil", + "LINE:Missing reagent: Soul Shard", + "UNIT_MANA:nil:nil", + ] + ); + c.frame(vec![]); + assert!(c.seen(&["LINE"]).is_empty(), "the callback ran once"); +} + +/// A refusal runs the callback too, whose lookup finds no record: `UNKNOWN` (`0x6e2a49`). +#[test] +fn a_refused_reagent_record_shows_unknown_at_its_answer() { + let mut c = refused_for_a_reagent(); + c.frame(vec![ + vec![values(ME, &[(MANA, 90)])], + vec![shard_answer(None)], + vec![values(ME, &[(MANA, 80)])], + ]); + assert_eq!( + c.seen(&["UNIT_MANA", "LINE"]), + [ + "UNIT_MANA:nil:nil", + "LINE:Missing reagent: UNKNOWN", + "UNIT_MANA:nil:nil", + ] + ); +} + +/// With no frame after them, the getters read through the host the cast state each packet left, +/// inside the call (`0x4e53a0`, `0x6e6e80`): Heal in flight after its start, popped after its +/// result, where the VM's copy, which no feed pushes, still holds it in flight; and +/// `SpellStopCasting` finds nothing to stop. +#[test] +fn the_cast_getters_read_each_packets_state_at_the_call() { + let mut c = client(); + cast_heal(&mut c); + let chunk = "return IsCurrentAction(1), SpellIsTargeting()"; + crate::net::handlers::dispatch( + c.app.world_mut(), + vec![SessionEvent::SpellStart { + caster: ME, + spell_id: HEAL, + cast_flags: 0, + cast_time_ms: 2500, + target: None, + ammo_display_id: None, + }], + ); + assert_eq!( + c.eval::<(Option, Option)>(true, chunk), + (Some(1), None) + ); + crate::net::handlers::dispatch( + c.app.world_mut(), + vec![SessionEvent::CastResult { + spell_id: HEAL, + success: true, + reason: None, + arg: None, + }], + ); + assert_eq!( + c.eval::<(Option, Option)>(true, chunk), + (None, None) + ); + assert_eq!( + c.eval::<(Option, Option)>(false, chunk), + (Some(1), None), + "the VM's copy" + ); + c.press("Stopped = SpellStopCasting()"); + assert_eq!(c.eval::>(false, "return Stopped"), None); +} + +/// The targeting word and the auto-repeat key read at the call too (`0x6e6cd0`, `0x6e9fd0`), each +/// written with no push after it: the cursor up for Heal and Heal repeating. `SpellStopTargeting` +/// tests the word at the call and ends it inside it (`0x6e6e30` → `0x6e4900`). +#[test] +fn the_targeting_word_and_the_auto_repeat_key_read_at_the_call() { + let mut c = client(); + let world = c.app.world_mut(); + world.resource_mut::().enter( + HEAL, + crate::spell::CastCommit::Spell, + 0x0002, + ); + world.resource_mut::().0 = Some(HEAL); + let chunk = "return SpellIsTargeting(), IsAutoRepeatAction(1)"; + assert_eq!( + c.eval::<(Option, Option)>(true, chunk), + (Some(1), Some(1)) + ); + assert_eq!( + c.eval::<(Option, Option)>(false, chunk), + (None, None), + "the VM's copy" + ); + c.press("Stopped = SpellStopTargeting()"); + assert_eq!(c.eval::>(false, "return Stopped"), Some(1)); + assert!(!c + .app + .world() + .resource::() + .active()); +} + +/// A refusal of ours with no item word reads -1 (`0x6e736e`), whose ask no answer ever matches +/// (`0x55bdf7`, `0x55be5d`), and one with a word of 0 asks nothing (`0x55ba42`): neither shows a +/// line, now or at an answer, though Heal's reagent is cached and missing from our bags. +#[test] +fn a_reagent_refusal_with_no_item_word_shows_no_line() { + let mut c = client(); + let mut reagents = [(0, 0); 8]; + reagents[0] = (SHARD, 1); + c.app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([( + HEAL, + SpellDisplay { + id: HEAL, + name: "Heal".into(), + reagents, + ..Default::default() + }, + )])), + ..crate::ui_action::Spells::empty_for_tests() + }); + c.app + .world_mut() + .resource_mut::() + .insert_template(SHARD, Some(crate::items::test_template("Soul Shard"))); + spy_lines(&mut c); + for arg in [None, Some(0)] { + refuse_for_a_reagent(&mut c, HEAL, arg); + c.frame(vec![vec![shard_answer(Some("Soul Shard"))]]); + assert!(c.seen(&["LINE"]).is_empty(), "{arg:?}"); + } + assert!( + c.app + .world_mut() + .resource_mut::() + .take_callbacks(0) + .is_empty(), + "a word of 0 keeps no callback" + ); +} + +/// The reagent arm reads the item word alone, never the spell record (`0x6e1e7f`): a refusal of +/// a spell the catalog lacks shows its line at the record's answer under `0xd2`, which the +/// callback picks off the reason (`0x6e29fe`), and with the record cached under the default id +/// `0x2c`, as the reporter skips the record's chain (`0x6e1a7c`). +#[test] +fn a_reagent_line_shows_for_a_spell_with_no_record() { + const UNLISTED: u32 = 99_999; + let mut c = client(); + spy_lines(&mut c); + c.eval::<()>( + false, + r#"ERR_SPELL_FAILED_S = "S:%s" ERR_SPELL_FAILED_REAGENTS = "R:%s""#, + ); + refuse_for_a_reagent(&mut c, UNLISTED, Some(SHARD)); + assert!(c.seen(&["LINE"]).is_empty(), "the record is asked"); + c.frame(vec![vec![shard_answer(Some("Soul Shard"))]]); + assert_eq!(c.seen(&["LINE"]), ["LINE:R:Missing reagent: Soul Shard"]); + refuse_for_a_reagent(&mut c, UNLISTED, Some(SHARD)); + assert_eq!(c.seen(&["LINE"]), ["LINE:S:Missing reagent: Soul Shard"]); +} diff --git a/crates/benilla-app/src/lanes/guard_tests.rs b/crates/benilla-app/src/lanes/guard_tests.rs index d2e32019f..167e3bc6b 100644 --- a/crates/benilla-app/src/lanes/guard_tests.rs +++ b/crates/benilla-app/src/lanes/guard_tests.rs @@ -75,11 +75,10 @@ pub(super) const NOT_YET_ON_A_LANE: &[(Row, &str, &str)] = &[ "::items::Items", "the record answers' callbacks still run off the frame's records rather than in the \ answer's list (`ItemCacheReceive` `0x55bdb0`, `0x55bf12`-`0x55bf39`): the \ - dress-up's (`0x504ba0`), the cast failure's redisplay (`0x6e29b0`, `0x6e2a90`), the \ - equipment display's (`0x5ed880`), and the lock-key tooltip's (`0x52ad10`), which \ - `drive_mouseover_tooltip` rebuilds each frame; and the combat log's (`0x6294b0`) and the \ - auction lists' (`0x4cca10`, `0x4ccc60`, `0x4cceb0`), which `run_answer_tail` runs after \ - the answer's list rather than in it", + dress-up's (`0x504ba0`), the equipment display's (`0x5ed880`), and the lock-key \ + tooltip's (`0x52ad10`), which `drive_mouseover_tooltip` rebuilds each frame; and the \ + combat log's (`0x6294b0`) and the auction lists' (`0x4cca10`, `0x4ccc60`, `0x4cceb0`), \ + which `run_answer_tail` runs after the answer's list rather than in it", ), ]; @@ -145,8 +144,8 @@ pub(super) const HOST_HELD_UNREAD: &[&str] = &[ "::spell::cast_send::ScriptCastPermit", "::spell::cast_send::unit_caster::UnitAttackOwed", // Quiet: the cast in flight, which `SpellStopCasting` and the bar's tests read through the - // host; `feed_cast_reports` pushes its view (its `READS_FRAME_FINAL` row), and the spell - // packets' events fire on the cast lane at their handlers. + // host, as of the drain's place, and the spell packets' events fire on the cast lane at their + // handlers. "::spell::inflight::ActiveChannel", "::spell::inflight::AutoRepeatActive", "::spell::inflight::PendingCast", @@ -160,8 +159,8 @@ pub(super) const HOST_HELD_UNREAD: &[&str] = &[ // call's before its next step; no feed reads it. "::spell::targeting::CursorPicks", // Quiet: the targeting word, which `SpellIsTargeting` (`0x6e6cd0`) reads through the host; - // `feed_cast_reports` pushes its view and the feed publishes the world pick's words for the - // next frame's input, and no feed fires off it. + // the feed publishes the world pick's words for the next frame's input, and no feed fires off + // it. "::spell::targeting::SpellTargeting", // Quiet: every selection writer signals `PLAYER_TARGET_CHANGED` in place (`0x49389c`), and the // getters' `"target"` reads it through the host (`0x515a86`); the feeds that read it push @@ -360,10 +359,6 @@ pub(super) const READS_FRAME_FINAL: &[(&str, &[&str])] = &[ ("::ui_aura::feed_unit_guids", &["net::GuidIndex", "net::ObjectStore", "target::Selection", "ui_party::GroupState"]), ("::ui_macro::load_macros", &["ui_action::ActionSlots"]), - ("::ui_cast::feed_cast_reports", &["items::Countdowns", "items::Items", "net::GuidIndex", - "net::ObjectStore", "spell::inflight::AutoRepeatActive", "spell::inflight::PendingCast", - "spell::inflight::QueuedMeleeSpell", "spell::targeting::SpellTargeting", - "ui_action::cast_lines::CastFailRedisplays"]), // The locks of the objects that went, forgotten as the frame left the index. ("::ui_items::locks::forget_gone_item_locks", &["net::GuidIndex"]), ("::ui_items::feed::feed_item_stats", &["items::Items", "net::HomeBind", "net::ObjectStore", @@ -1783,8 +1778,7 @@ pub(super) const LANE_READS_FRAME_FINAL: &[(&str, &[&str])] = &[ // tokens' live reads; it reads the index and the selection as the frame left them (#2217). ("unit views", &["net::GuidIndex", "net::ObjectStore", "target::Selection", "ui_party::GroupState"]), - ("cast reports", &["items::Countdowns", "items::Items", "net::GuidIndex", "net::ObjectStore", - "ui_action::cast_lines::CastFailRedisplays"]), + ("cast reports", &["items::Items", "net::ObjectStore"]), ("item watchers", &["items::Countdowns", "items::Items", "names::NameCache", "net::GuidIndex", "net::ObjectStore", "ui_trade::TradeSession"]), ("auction house", &["items::Items", "names::NameCache", "net::SelfGuid", diff --git a/crates/benilla-app/src/lanes/host_read_tests.rs b/crates/benilla-app/src/lanes/host_read_tests.rs index 26d62372a..41467a1ad 100644 --- a/crates/benilla-app/src/lanes/host_read_tests.rs +++ b/crates/benilla-app/src/lanes/host_read_tests.rs @@ -43,8 +43,8 @@ use crate::game_plugins::schedule_tests::{census, type_names_available, SyncPoin /// The most rows [`super::guard_tests::READS_FRAME_FINAL`] may hold, and the most inputs across /// them: each only falls, so lower it when a row or an input goes. -const FRAME_FINAL_FEEDS: usize = 9; -const FRAME_FINAL_INPUTS: usize = 43; +const FRAME_FINAL_FEEDS: usize = 8; +const FRAME_FINAL_INPUTS: usize = 34; /// The systems that may run after the dispatch (`apply_net_updates`) and run ahead of the drain /// (`fire_in_order`), by a suffix of the system's name, with what each writes of the state a @@ -72,11 +72,6 @@ const BETWEEN_THE_DISPATCH_AND_THE_DRAIN: &[(&str, &[&str], &str)] = &[ "the arrival snap `0x7c6420` (`0x618dfc`) is written inside the packet"), ("::transport::compose_riders", &["Transform", "net::motion::remote::RemoteMotion"], "the arrival snap `0x7c6420` (`0x618dfc`) is written inside the packet"), - ("::ui_cast::feed_cast_reports", &["ui_action::cast_lines::CastFailRedisplays"], - "holds the refusals that wait on their item's record, which it tests to queue the cast \ - lane's retry behind the frame's packets, where the reference shows each in the record's \ - callback (`ShowItemCastFailure` `0x6e29b0`), inside `ItemCacheReceive` `0x55bdb0` in the \ - answer's handler"), ("::ui_char::feed_char", &["UiScript", "ui_char::AmmoRecordHits"], "a field event's listeners read its push, and it takes the ammo getters' record hits, a \ `UNIT_INVENTORY_CHANGED` each behind it, where the reference's hit registers no callback \ @@ -140,6 +135,9 @@ const AFTER_THE_PACKETS_IN_THE_REFERENCE: &[(&str, &[&str], &str)] = &[ &["ui_pet::PetBar"], "drops the pet's cooldown log, run only while no in-game interface \ is up, so no listener exists; it goes with the log"), + ("::ui_cast::views::drop_cast_views", &["ui_cast::views::CastViews"], + "drops the cast views' log, run only while no in-game interface \ + is up, so no listener exists; it goes with the log"), ("::ui_chat::language::load_language_words", &["ui_chat::language::ChatLanguages"], "loads `LanguageWords.dbc` once, the first frame the install is open, ahead of every \ world packet, as the reference loads it at startup (`StaticDBLoadAll` `0x53f8b0` → \ diff --git a/crates/benilla-app/src/lanes/tests.rs b/crates/benilla-app/src/lanes/tests.rs index 4eba76404..ef616cb7e 100644 --- a/crates/benilla-app/src/lanes/tests.rs +++ b/crates/benilla-app/src/lanes/tests.rs @@ -519,15 +519,12 @@ fn cast_stops(_: In, mut reports: ResMut App { - let mut app = client(|app| { + client(|app| { app.add_plugins(crate::ui_cast::UiCastPlugin); handlers(app); - }); - app.world_mut() - .init_resource::(); - app + }) } /// A cast's report fires inside its packet's handler (`SPELLCAST_STOP` from `SMSG_CAST_RESULT`, diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index 01ae63073..1fffa9858 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -317,6 +317,13 @@ fn run_callback( } Cb::CooldownQuery => {} Cb::Stationery => run_answer_step(world, paper_answered, entry), + // `ShowItemCastFailure 0x6e29b0`, for a record and a refusal alike: the line again, + // on the cast lane here. + Cb::CastFailure { spell, reason } => { + if let Some(mut reports) = world.get_resource_mut::() { + reports.redisplay(spell, reason, entry); + } + } // `QUEST_LOG_UPDATE` for a ready record only (`0x4e0838`), held and parked here. Cb::QuestLog if answered => crate::ui_items::feed::quest_log_answered(world), // The tooltips' rebuilds for a found record only (`0x52e551`, `0x52f826`). @@ -416,6 +423,7 @@ fn run_record_callback( | Cb::ItemCooldown | Cb::CooldownQuery | Cb::Stationery + | Cb::CastFailure { .. } | Cb::QuestLog | Cb::Tooltip | Cb::TradeSkill diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 4dcebedaf..f2c713675 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -4938,10 +4938,11 @@ mod tests { crate::dev::add_every_instrument, ); let (writers, unhosted) = vm_writers_and_the_unhosted(&mut app); - // The census reads the client's systems: ninety writers at least in either build, and - // where the build carries type names, a feed and two frame polls among them. + // The census reads the client's systems: eighty writers at least in either build, a floor + // the feeds' going live lowers toward, and where the build carries type names, a feed and + // two frame polls among them. assert!( - writers.len() >= 90, + writers.len() >= 80, "the census reads the client's VM writers: {} read", writers.len() ); diff --git a/crates/benilla-app/src/script_calls/live.rs b/crates/benilla-app/src/script_calls/live.rs index 578993873..215814f3e 100644 --- a/crates/benilla-app/src/script_calls/live.rs +++ b/crates/benilla-app/src/script_calls/live.rs @@ -171,6 +171,9 @@ live_reads! { /// The targeting word's halves and `SpellCanTargetUnit`'s unit leg; absent in a harness /// without the unit checks' inputs. targeting: Option>, + /// Our cast's state: the in-flight slot, the targeting word, the auto-repeat key and the + /// attack flag; absent in a harness without the cast state. + cast: Option>, #[cfg(test)] probe: tests::ProbeLive<'static>, } diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index aa07313b9..855985f4f 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -223,7 +223,8 @@ pub(crate) fn reaches_tail( fail.arg, spells.and_then(|s| s.catalog.get(fail.spell_id)), spells.map(|s| &s.forms), - // The lookup with the callback `0x6e29b0` (`0x6e1ea4`). + // The lookup (`0x6e1ea4`), asking on a miss; the line's own registers the callback + // `0x6e29b0` ([`crate::ui_action::CastFailLines`]). |item| items.template(item, 0, Ask::Event(net)).is_some(), ) } @@ -2336,7 +2337,6 @@ mod tests { crate::ui_cast::CastReport::TradeSkillDisarm => "DISARM", crate::ui_cast::CastReport::AutoRepeat(true) => "START_AUTOREPEAT", crate::ui_cast::CastReport::AutoRepeat(false) => "STOP_AUTOREPEAT", - crate::ui_cast::CastReport::Redisplay => "REDISPLAY", crate::ui_cast::CastReport::Line(e) => e.key, }) .collect() diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index 71135de4b..aae8bd300 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -830,7 +830,6 @@ mod tests { .add_message::() .init_resource::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -899,22 +898,39 @@ mod tests { crate::creature_anim::apply_unit_edits(app.world_mut()); } - /// `SpellStopCasting()`, its answer, with the cast's cancel lent as the host, which applies + /// `SpellStopCasting()`, its answer, with the world lent as the host: its test reads the + /// cast state at the call ([`crate::ui_cast::CastLive`]), and the cast's cancel applies /// the call inside the verb as `crate::script_calls` does. fn stop_casting(app: &mut App) -> Option { - app.world_mut() - .run_system_once(|mut script: NonSendMut, mut cancel: SelfCancel| { - let mut host = crate::script_calls::FnHost(|_: &mut UiScript, call| { - if call == ScriptCall::SpellStopCasting { - cancel.stop_casting(); - } - }); - script.hosted(&mut host, |s| { - s.eval::>("return SpellStopCasting()") - }) - }) - .unwrap() - .unwrap() + use crate::script_calls::live::LiveFamily; + use benilla_ui::script::{CallHost, CallTail}; + use bevy::ecs::system::SystemState; + + struct Host<'a>(&'a mut World); + + impl CallHost for Host<'_> { + fn apply(&mut self, _: &mut UiScript, call: ScriptCall) -> Option { + if call == ScriptCall::SpellStopCasting { + let mut cancel = SystemState::::new(self.0); + cancel.get_mut(self.0).stop_casting(); + cancel.apply(self.0); + } + None + } + + fn read_live(&mut self, ask: &mut benilla_ui::script::live::LiveAsk<'_>) -> bool { + let mut live = SystemState::::new(self.0); + live.get(self.0).answer(ask) + } + } + + let world = app.world_mut(); + let mut script = world.remove_non_send_resource::().expect("a VM"); + let answer = script.hosted(&mut Host(world), |s| { + s.eval::>("return SpellStopCasting()") + }); + world.insert_non_send_resource(script); + answer.unwrap() } #[test] diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 8628ec090..c797fa681 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -1146,7 +1146,8 @@ fn cast_result( arg, spells.and_then(|s| s.catalog.get(spell_id)), spells.map(|s| &s.forms), - // The packet's lookup with the callback `0x6e29b0` (`0x6e1ea4`). + // The packet's lookup (`0x6e1ea4`), asking on a miss; the line's own registers the + // callback `0x6e29b0` ([`crate::ui_action::CastFailLines`]). |item| items.is_some_and(|i| i.template(item, 0, Ask::Event(net)).is_some()), ); // The line, then the tail: the spell in flight popped with its failed flag and no packet diff --git a/crates/benilla-app/src/spell/report.rs b/crates/benilla-app/src/spell/report.rs index a421ef86a..8e836ccbd 100644 --- a/crates/benilla-app/src/spell/report.rs +++ b/crates/benilla-app/src/spell/report.rs @@ -400,7 +400,6 @@ mod tests { CastReport::TradeSkillDisarm => "DISARM".into(), CastReport::AutoRepeat(true) => "START_AUTOREPEAT".into(), CastReport::AutoRepeat(false) => "STOP_AUTOREPEAT".into(), - CastReport::Redisplay => "REDISPLAY".into(), CastReport::Line(e) => e.key.into(), }) .collect() diff --git a/crates/benilla-app/src/ui_action/cast_fail.rs b/crates/benilla-app/src/ui_action/cast_fail.rs index 11c4590d1..c054e6bd1 100644 --- a/crates/benilla-app/src/ui_action/cast_fail.rs +++ b/crates/benilla-app/src/ui_action/cast_fail.rs @@ -436,6 +436,16 @@ pub(super) fn cast_fail_text( // player's chain and its `_PET` flag are skipped (`0x6e1a7c`), and the pet's handler leaves // (`0x6e8f04`). let (mut error, pet_text) = match (caster, spell) { + // The item-cache callback `0x6e29b0` picks its id off the reason alone, `0xd2` for `0x5c` + // and `0xd1` for `0x78`, reading no spell record (`0x6e29fe`-`0x6e2a0d`). + (Caster::Player, _) if args.redisplay => ( + match reason { + 0x5C => Some("ERR_SPELL_FAILED_REAGENTS"), + 0x78 => Some("ERR_SPELL_FAILED_TOTEMS"), + _ => None, + }, + false, + ), (Caster::Player, Some(d)) => ( player_error_key(reason, d), !args.redisplay && trains_pet(d), diff --git a/crates/benilla-app/src/ui_action/cast_lines.rs b/crates/benilla-app/src/ui_action/cast_lines.rs index fa990f1b3..bd3289e89 100644 --- a/crates/benilla-app/src/ui_action/cast_lines.rs +++ b/crates/benilla-app/src/ui_action/cast_lines.rs @@ -2,8 +2,8 @@ //! table, and the combat-log line beside it. `ReportCastFailure 0x6e1a00` builds the text, shows it //! (`DisplayError`, `0x6e21dd`) and logs it (`0x62c360`, `0x6e21fc`) inside the cast, so the line //! is resolved where the report is signalled: inside the script call that raised it -//! ([`crate::script_calls`]), or by the frame's report feed ([`crate::ui_cast`]). The text layers -//! are [`super::cast_fail`]'s. +//! ([`crate::script_calls`]), or at its place on the cast lane ([`crate::ui_cast`]). The text +//! layers are [`super::cast_fail`]'s. use bevy::ecs::system::SystemParam; use bevy::prelude::*; @@ -16,15 +16,8 @@ use crate::query_cache::Ask; use crate::ui_chat::combat::PendingCombat; use super::cast_fail::{self, CastFailLine}; -use super::errors::{first_missing_totem, first_short_reagent}; use super::{CastFail, Caster, Shown, Spells}; -/// The refusals whose item had no cached record when their line was due: the reference's first -/// pass exits past the line (`0x6e1eab`), and the cache callback `0x6e29b0` shows it once the -/// record answers, with no log line. -#[derive(Resource, Default)] -pub(crate) struct CastFailRedisplays(pub(crate) Vec); - /// The DBC name tables the cast-fail argument arms read; each is absent without game data. The /// shapeshift forms ride [`Spells`]. #[derive(SystemParam)] @@ -65,10 +58,8 @@ pub(crate) struct CastFailLines<'w, 's> { sub_classes: Option>, names: FailNameTables<'w>, self_q: Query<'w, 's, &'static ObjectStore, With>, - objects: crate::net::Objects<'w, 's>, items: Res<'w, Items>, commands: Res<'w, NetCommands>, - waiting: ResMut<'w, CastFailRedisplays>, } impl CastFailLines<'_, '_> { @@ -77,14 +68,9 @@ impl CastFailLines<'_, '_> { self.spells.as_deref() } - /// Whether a line waits on its item's record. - pub(crate) fn any_waiting(&self) -> bool { - !self.waiting.0.is_empty() - } - /// `fail`'s line and its log line, `None` where the reference leaves before both now; an empty - /// line shows nothing and still logs. A line whose item record is not cached waits in - /// [`CastFailRedisplays`]. + /// line shows nothing and still logs. A line whose item record is not cached waits on the + /// record's answer ([`crate::items::RecordCallback::CastFailure`]). pub(crate) fn resolve(&mut self, script: &UiScript, fail: CastFail) -> Option { let line = self.line(script, fail)?; // The reason, its wire word and both resolved buffers, so a declined arm is readable @@ -100,15 +86,6 @@ impl CastFailLines<'_, '_> { }) } - /// The waiting lines whose item record has answered, each shown with no log line; the rest - /// keep waiting. - pub(crate) fn redisplays(&mut self, script: &UiScript) -> Vec { - std::mem::take(&mut self.waiting.0) - .into_iter() - .filter_map(|fail| self.resolve(script, fail).map(|r| r.shown)) - .collect() - } - /// The displayed line first, the log reads from it: the reference's order. fn line(&mut self, script: &UiScript, fail: CastFail) -> Option { let CastFail { @@ -148,41 +125,35 @@ impl CastFailLines<'_, '_> { .requirement_display_name(ITEM_CLASS_PROJECTILE, 1u32 << (arg & 31)) }), // `0x78` and `0x5c` (`0x6e1e7f`), the player's arms only (the pet's table sends both to - // its generic arm): the name of argument 1's item, the entry `0x6e4000` found; a - // word-less refusal names the failing slot as the pre-send check finds it. + // its generic arm): the name of argument 1's item, the entry `0x6e4000` found, read + // with no spell record. The server's refusal carries no word for either (vmangos + // `Spell.cpp:4407-4431`), which reads -1 (`0x6e736e`): its lookup asks for an entry + // whose refusal no node matches (`0x55bdf7`, `0x55be5d`), so neither line nor log ever + // shows; a word of 0 asks nothing and shows nothing (`0x55ba42`). 0x78 | 0x5c if !pet => { - let d = d?; - let self_store = self.self_q.iter().next(); - let failing = fail.arg.or_else(|| { - if reason == 0x78 { - self_store - .and_then(|s| first_missing_totem(d, s, &self.objects)) - // No self store yet: name the first tool. - .or_else(|| d.totems.iter().copied().find(|&t| t != 0)) - } else { - self_store - .and_then(|s| first_short_reagent(d, s, &self.objects)) - .or_else(|| d.reagents.iter().map(|&(id, _)| id).find(|&id| id != 0)) - } - })?; - // The failure handler's lookup with the callback `0x6e29b0` (`0x6e1ea4`) on a - // first pass; its redisplay only reads. - let ask = if fail.redisplay { - Ask::Read + let failing = fail.arg.filter(|&entry| entry != 0)?; + // The callback `0x6e29b0` looks the record up again with no callback of its own + // (`0x6e2a27`), a refusal's finding none: the literal `UNKNOWN` (`0x6e2a49`). + if fail.redisplay { + let cached = self.items.template(failing, 0, Ask::Read); + Some(cached.map_or_else(|| "UNKNOWN".to_string(), |i| i.name.clone())) } else { - Ask::Event(&self.commands) - }; - let cached = self.items.template(failing, 0, ask).map(|i| i.name.clone()); - match cached { - Some(name) => Some(name), - // Answered unknown: the reference's callback literal (`0x838044`). - None if self.items.template_answered_unknown(failing) => { - Some("UNKNOWN".to_string()) - } - None => { - // Pending: shown as a redisplay once it answers, never logged. - self.waiting.0.push(fail.requeued()); - return None; + // The failure handler's lookup with the callback `0x6e29b0` and the reason, no + // dedupe (`0x6e1e82`-`0x6e1ea4`): a miss leaves past the line and its log + // (`0x6e1eab`), and the record's answer shows it ([`crate::ui_cast`]). + let cached = self.items.template(failing, 0, Ask::Event(&self.commands)); + match cached { + Some(info) => Some(info.name.clone()), + None => { + self.items.wait( + failing, + crate::items::RecordCallback::CastFailure { + spell: spell_id, + reason, + }, + ); + return None; + } } } } diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 0a654a176..747a548f9 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -83,11 +83,12 @@ impl CastFail { } } - /// The same entry, marked as a [`Self::redisplay`]. - pub(crate) const fn requeued(self) -> Self { + /// The item-cache callback's retry of our refused line whose item had no record + /// (`ShowItemCastFailure 0x6e29b0`), naming the item, marked as a [`Self::redisplay`]. + pub(crate) const fn item_redisplay(spell_id: u32, reason: u8, item: u32) -> Self { Self { redisplay: true, - ..self + ..Self::local_arg(spell_id, reason, item) } } } diff --git a/crates/benilla-app/src/ui_action/feed_tests.rs b/crates/benilla-app/src/ui_action/feed_tests.rs index 892b6f003..bbc15787b 100644 --- a/crates/benilla-app/src/ui_action/feed_tests.rs +++ b/crates/benilla-app/src/ui_action/feed_tests.rs @@ -188,8 +188,8 @@ fn a_notice_under_the_loading_cover_fires_at_the_reveal() { assert!(app.world().resource::().texts().is_empty()); } -/// The cast reports' feed ([`crate::ui_cast::feed_cast_reports`]) over a VM, with `reports` -/// queued and `spells` as the catalog. +/// The cast reports' lane ([`crate::ui_cast::signal_reports`]) over a VM, with `reports` queued +/// and `spells` as the catalog. fn report_feed(spells: crate::ui_action::Spells, reports: crate::ui_cast::CastReports) -> App { let (tx, _rx) = crossbeam_channel::unbounded(); let mut app = App::new(); @@ -198,7 +198,6 @@ fn report_feed(spells: crate::ui_action::Spells, reports: crate::ui_cast::CastRe .insert_resource(spells) .init_resource::() .init_resource::() - .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 6b4a093e2..e95a288f4 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -15,7 +15,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets}; mod cast_fail; mod cast_lines; -pub(crate) use cast_lines::{CastFailLines, CastFailRedisplays}; +pub(crate) use cast_lines::CastFailLines; mod drain; // A `UseAction` press, applied in call order by `crate::script_calls`. pub(crate) use drain::{ @@ -522,7 +522,6 @@ impl Plugin for UiActionPlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() .init_resource::() .add_systems(Startup, load_spells.after(AssetSet::Open)) .add_systems(Startup, load_spell_mechanics.after(AssetSet::Open)) diff --git a/crates/benilla-app/src/ui_cast.rs b/crates/benilla-app/src/ui_cast.rs index 7d94dae5a..ac0f41187 100644 --- a/crates/benilla-app/src/ui_cast.rs +++ b/crates/benilla-app/src/ui_cast.rs @@ -2,20 +2,18 @@ //! (`ReportCastFailure 0x6e1a00`), the wire edges that become the `SPELLCAST_*` events stock //! `CastingBarFrame.lua:6-13` registers for, named from `Spell.dbc`, and the tradeskill repeat's //! disarm. A script call's reports are signalled inside the call ([`crate::script_calls`]), the -//! rest from their lane in the packets' order ([`fire_cast_reports`]), after the frame's feed has -//! pushed the VM's cast state ([`feed_cast_reports`]). The cast lifecycle is `crate::spell`'s. +//! rest from their lane in the packets' order ([`fire_cast_reports`]), where the getters read the +//! cast state each packet left ([`CastLive`]). The cast lifecycle is `crate::spell`'s. use bevy::ecs::system::SystemParam; use bevy::prelude::*; use benilla_ui::script::{ScriptValue, UiScript}; -use crate::spell::CastState; use crate::ui_action::{CastFail, CastFailLines, MessageSink, Shown, Spells}; -use crate::ui_unit::UnitFeed; mod views; -pub(crate) use views::{slot_moves_here, CastViews}; +pub(crate) use views::{slot_moves_here, CastLive, CastViews}; /// One edge of our own cast, queued by the spell's packet handlers, its reports and its cancels. pub(crate) enum CastBarEdge { @@ -62,8 +60,6 @@ pub(crate) enum CastReport { /// send stores it (`0x6e594c`, `0x6e5952`), `STOP_AUTOREPEAT_SPELL` (436) as /// `StopAutoRepeatSpell` clears it (`0x6ea16a`, `0x6ea170`). AutoRepeat(bool), - /// The frame's retry of the refused lines that wait on an item's record (`0x6e29b0`). - Redisplay, /// A line the cast validator shows itself through `CGGameUI::DisplayError 0x496720`, outside /// `ReportCastFailure`: a learn rung's, or the tame rung's ahead of its report. Line(crate::ui_action::UiError), @@ -97,6 +93,13 @@ impl CastReports { self.push(CastReport::Line(line)); } + /// `ShowItemCastFailure 0x6e29b0`, run by the item answer of a refused line that waited on + /// it ([`crate::items::RecordCallback::CastFailure`]): the line again, under the plain key, + /// with no log line and no event. + pub(crate) fn redisplay(&mut self, spell_id: u32, reason: u8, item: u32) { + self.refused(CastFail::item_redisplay(spell_id, reason, item)); + } + /// `SMSG_PET_CAST_FAILED` (`0x6e8eb0`): one message, no log line and no event. pub(crate) fn pet_refused(&mut self, spell_id: u32, reason: u8) { self.refused(CastFail::pet(spell_id, reason)); @@ -399,7 +402,6 @@ impl CastReporter<'_, '_> { CastReport::AutoRepeat(false) => { out.push(Ready::Event("STOP_AUTOREPEAT_SPELL", vec![])) } - CastReport::Redisplay => out.extend(self.redisplays(script)), CastReport::Line(e) => { let get = |key: &str| script.lua().globals().get::(key).ok(); if let Some(text) = crate::ui_action::ui_error_text(&e, &get) { @@ -411,15 +413,6 @@ impl CastReporter<'_, '_> { out } - /// The refused lines whose item record has answered since (`0x6e29b0`). - pub(crate) fn redisplays(&mut self, script: &UiScript) -> Vec { - let shown = self.lines.redisplays(script); - shown - .into_iter() - .filter_map(|s| self.line(script, s)) - .collect() - } - /// A line's world half done, its event readied: a chat line's too, which `DisplayError` /// writes in place (`0x49a870`). pub(crate) fn line(&mut self, script: &UiScript, shown: Shown) -> Option { @@ -476,34 +469,6 @@ impl CallReports<'_, '_> { } } -/// Push the cast state the frame's packets and cancels left ([`CastState`]), which -/// `SpellStopCasting` (`0x6e6e80`) and the bar's checked tests read: a queued strike and the spell -/// awaiting its click count as in flight, so the first Esc stops it and `ToggleGameMenu` skips -/// `ClearTarget()` (`UIParent.lua:1489-1492`). The reports made outside any script call fire from -/// their lane after every feed ([`fire_cast_reports`]), each listener reading the view its own -/// packet left, which the views lane pushes at its place ([`views`]); a refused line that waits -/// on its item's record is retried there, behind the frame's packets. -#[allow(clippy::type_complexity)] // a Bevy system's full input set -pub(crate) fn feed_cast_reports( - // The reports and the cast state, which the host's casts write, and the views' log. - mut vm: crate::script_calls::VmWith<( - ResMut<'static, CastReports>, - CastReporter<'static, 'static>, - CastState<'static, 'static>, - Option>, - )>, -) { - vm.turn(|script, (mut reports, reporter, cast_state, mut views)| { - let Some(script) = script else { - return; - }; - views::feed_live(script, views.as_deref_mut(), cast_state.view()); - if reporter.lines.any_waiting() { - reports.push(CastReport::Redisplay); - } - }); -} - /// The cast lane's `fire_until` ([`crate::lanes`]): each report the lane holds while `until` /// admits it, readied and signalled at its place with the host lent, as the spell handlers signal /// inside their packets (`0x6e1a00`, `0x6e7a2d`, `0x6e7550`); the lane stops after a report whose @@ -537,24 +502,15 @@ fn fire_cast_reports( } } -/// The frame's cast-state push, then every report queued fired alone, for a test that drives the -/// reports without the other lanes. -#[cfg(test)] -pub(crate) fn signal_reports(world: &mut World) { - use bevy::ecs::system::RunSystemOnce; - world.run_system_once(feed_cast_reports).unwrap(); - fire_cast_lane(world); -} - /// The session's end drops the reports no VM will hear, as the chat lane drops its lines: the /// Lua state they were for is replaced at the session end. fn end_session_reports(mut reports: ResMut) { reports.clear(); } -/// The reports queued, fired alone, for a test that drives them without the other lanes. +/// Every report queued, fired alone, for a test that drives the reports without the other lanes. #[cfg(test)] -pub(crate) fn fire_cast_lane(world: &mut World) { +pub(crate) fn signal_reports(world: &mut World) { use bevy::ecs::system::RunSystemOnce; let until = crate::lanes::Until::all(); while world @@ -568,8 +524,7 @@ pub(crate) fn fire_cast_lane(world: &mut World) { } } -/// The cast-state push, before the VM ticks so an edge and its first `OnUpdate` share a frame, and -/// after the local cancel so a move or Esc kills the bar on the next tick; and the cast lane. +/// The cast lane and the cast views' log. pub(crate) struct UiCastPlugin; impl Plugin for UiCastPlugin { @@ -583,13 +538,6 @@ impl Plugin for UiCastPlugin { fire_cast_reports, None, ) - .add_systems( - Update, - // After the spell's local cancel, whose state this pushes the same frame. - feed_cast_reports - .in_set(UnitFeed) - .after(crate::spell::LocalCancel), - ) .add_systems( OnExit(crate::client_state::ClientState::InWorld), end_session_reports.in_set(crate::ui_script::SessionEnd::Clears), diff --git a/crates/benilla-app/src/ui_cast/views.rs b/crates/benilla-app/src/ui_cast/views.rs index 9877c4178..9070e2c91 100644 --- a/crates/benilla-app/src/ui_cast/views.rs +++ b/crates/benilla-app/src/ui_cast/views.rs @@ -7,17 +7,19 @@ //! `0x6e9fd0`, `SpellIsTargeting` `0x6e6cd0`, `SpellStopCasting` `0x6e6e80`), so a listener reads //! its own packet's. Here every handler of the frame runs before any listener, so the view each //! packet left is logged, stamped ahead of what the packet raised ([`CastViews`]); the views lane -//! pushes the frame's start, then each packet's view at its place among the other lanes' items. -//! A listener's call that moves the state moves it live, after every handler, so what it moved -//! is the live state's from then on: each later view carries the call's fields as they stand -//! live ([`Moved`]), and the rest as its packet left them. +//! replays the frame's start, then each packet's view at its place among the other lanes' items, +//! and the getters read it there through the host ([`CastLive`]). A listener's call that moves the +//! state moves it live, after every handler, so what it moved is the live state's from then on: +//! each later view carries the call's fields as they stand live ([`Moved`]), and the rest as its +//! packet left them. The VM's copy follows the same views, for a read with no host to ask. use std::collections::VecDeque; -use bevy::ecs::system::SystemState; +use bevy::ecs::system::{SystemParam, SystemState}; use bevy::prelude::*; -use benilla_ui::script::{CastView, UiScript}; +use benilla_ui::script::live::LiveAsk; +use benilla_ui::script::{CastView, CastViewNow, UiScript}; use crate::lanes::{Stamp, Until}; use crate::spell::CastState; @@ -33,8 +35,10 @@ pub(crate) struct CastViews { last: Option, /// The drain has pushed the start. started: bool, - /// The live view the VM last took, from the frame's feed or a call: a call that leaves it as - /// it stands pushes nothing, so the drain's view stays its listener's packet's. + /// The packet's view the drain last replayed: where its listeners stand. + at: Option, + /// The live view as the last packet or call left it: a call that leaves it as it stands + /// pushes nothing, so the drain's view stays its listener's packet's. live: Option, /// The view the VM holds. shown: Option, @@ -152,15 +156,40 @@ impl CastViews { /// 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) { + self.at = Some(view); let view = self.called.over(view, self.live.unwrap_or(view)); self.show(script, view); } + + /// The view a getter reads at the call, `live` being the state as it stands: inside the + /// drain, the packet's view the lane last replayed, with the fields the drain's calls moved as + /// they stand live; else the live state. + pub(crate) fn here(&self, live: CastView) -> CastView { + match self.at.filter(|_| self.started) { + Some(at) => self.called.over(at, live), + None => live, + } + } } -/// `view` into the VM with no log kept, in a harness without one. -fn push(script: &mut UiScript, view: CastView) { - script.set_cast_view(view); - script.set_spell_targeting(view.targeting.is_some()); +/// Our cast's state as the getters read it inside the call (`IsCurrentAction` `0x4e53a0`, +/// `IsAutoRepeatAction` `0x4e55b0` → `0x6e9fd0`, `SpellIsTargeting` `0x6e6cd0`, `SpellStopCasting` +/// `0x6e6e80`): the live state, as of the drain's place while the views lane replays the frame's +/// packets ([`CastViews::here`]). +#[derive(SystemParam)] +pub(crate) struct CastLive<'w, 's> { + state: CastState<'w, 's>, + /// The views' log; absent in a harness without the cast lanes, which reads the live state. + views: Option>, +} + +impl crate::script_calls::live::LiveFamily for CastLive<'_, '_> { + fn answer(&mut self, ask: &mut LiveAsk<'_>) -> bool { + ask.answer(|_: &CastViewNow| { + let live = self.state.view(); + Some(self.views.as_deref().map_or(live, |v| v.here(live))) + }) + } } /// The live view off the world, through a reader built once. @@ -202,7 +231,8 @@ 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; with its slot at the stamp its handler moved it, when it -/// named one ([`slot_moves_here`]). +/// named one ([`slot_moves_here`]). The view it left is the live one a call after it is told +/// against. fn packet_end(world: &mut World, at: Stamp) { let moves = SLOT_MOVES.take().filter(|s| *s > at); let Some(view) = live_view(world) else { @@ -211,6 +241,7 @@ fn packet_end(world: &mut World, at: Stamp) { let Some(mut views) = world.get_resource_mut::() else { return; }; + views.live = Some(view); let Some(last) = views.last else { return; }; @@ -274,6 +305,7 @@ fn end_cast_views(world: &mut World) { views.entries.clear(); views.start = None; views.started = false; + views.at = None; views.called = Moved::default(); let Some(view) = view else { return; @@ -295,18 +327,6 @@ fn drop_cast_views(mut views: ResMut) { }; } -/// The frame's feed of the live view, which the drain's start replaces while the frame logged -/// any ([`super::feed_cast_reports`]). -pub(super) fn feed_live(script: &mut UiScript, views: Option<&mut CastViews>, view: CastView) { - match views { - Some(views) => { - views.show(script, view); - views.live = Some(view); - } - None => push(script, view), - } -} - /// The log, its packet hooks, the lane, its end and the drop under the exact complement of the /// drain's gate. pub(super) fn add_cast_views_lane(app: &mut App) { diff --git a/crates/benilla-app/src/ui_items/mod.rs b/crates/benilla-app/src/ui_items/mod.rs index a8a82e88c..fbb2843d1 100644 --- a/crates/benilla-app/src/ui_items/mod.rs +++ b/crates/benilla-app/src/ui_items/mod.rs @@ -1037,11 +1037,8 @@ impl Plugin for UiItemsPlugin { .in_set(crate::ui_script::UiFeed) .before(crate::ui_char::feed_char), // The item-tooltip store answers in the feed, so the next frame's input - // records, a re-hover among them, see the answer, and seats a new VM before its world entry; a - // refused cast's item name reads the store it leaves. - feed_item_stats - .in_set(UnitFeed) - .before(crate::ui_cast::feed_cast_reports), + // records, a re-hover among them, see the answer, and seats a new VM before its world entry. + feed_item_stats.in_set(UnitFeed), // The equip callbacks a lookup ran on a record the VM's copy lacked, after // the record feed has pushed it; an answer runs its own at its packet. crate::ui_bind_confirm::replay_equip_records.in_set(crate::ui_script::UiDrain), diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 60dcd4178..ea9a2ca08 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -320,10 +320,10 @@ impl ActionState { } /// The player's cast state as the reference's globals hold it, which the action bar's checked -/// tests (`0x4e53a0`, `0x4e55b0`) and `SpellStopCasting` (`0x6e6e80`) read live. The app pushes it -/// each frame, as each packet left it where that packet's events fire, and after every script call -/// that moved it, so a listener reads its own packet's and a script's next line what the call -/// left. +/// tests (`0x4e53a0`, `0x4e55b0`) and `SpellStopCasting` (`0x6e6e80`) read live, through the lent +/// host ([`CastViewNow`]), so a listener reads its own packet's and a script's next line what the +/// call left. The app also pushes it, as each packet left it and after every script call that +/// moved it, for a read with no host to ask. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct CastView { /// The spell in the in-flight slot (`0xceca88`, `0x6e3d10`): a cast from its attempt to its @@ -344,6 +344,38 @@ pub struct CastView { pub item: Option, } +/// The cast state's question to the lent host: our cast's globals as they stand where the getter +/// runs ([`CastView`]), which `IsCurrentAction`, `IsAutoRepeatAction`, `SpellStopCasting` and the +/// targeting word's tests read inside the call. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CastViewNow; + +impl super::live::LiveQuery for CastViewNow { + type Answer = CastView; +} + +/// The cast state at the call ([`CastViewNow`]), else as the app last pushed it. +pub(crate) fn cast_view(lua: &Lua) -> CastView { + super::live::ask(lua, &CastViewNow).unwrap_or_else(|| { + lua.app_data_ref::() + .expect("model app_data") + .cast_view + }) +} + +/// `IsTargeting 0x6e48a0` at the call: the targeting word is up, as [`cast_view`] holds its spell, +/// else as the app last pushed it. +pub(crate) fn spell_targeting(lua: &Lua) -> bool { + super::live::ask(lua, &CastViewNow).map_or_else( + || { + lua.app_data_ref::() + .expect("model app_data") + .spell_targeting + }, + |view| view.targeting.is_some(), + ) +} + impl CastView { /// What `SpellStopCasting` (`0x6e6e80`) can stop: the auto-repeat (`0x6e9fd0`), else a spell /// in flight (`IsCastInFlight 0x6e3d30`), never a channel, whose spell is not in flight, nor a @@ -454,7 +486,7 @@ impl super::UiScript { } /// Push the player's cast state ([`CastView`]), which the checked tests and `SpellStopCasting` - /// read. + /// read with no host to ask ([`CastViewNow`]). pub fn set_cast_view(&mut self, view: CastView) { self.model_mut().cast_view = view; } @@ -692,12 +724,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } } - /// 1 or nil for a slot's test against the live cast state. + /// 1 or nil for a slot's test against the cast state at the call. fn live_flag(lua: &Lua, action: u32, pick: impl Fn(&ActionState, &CastView) -> bool) -> Value { let state = state_of(lua, action); - let model = lua.app_data_ref::().expect("model app_data"); match state { - Some(s) if pick(&s, &model.cast_view) => Value::Integer(1), + Some(s) if pick(&s, &cast_view(lua)) => Value::Integer(1), _ => Value::Nil, } } @@ -756,12 +787,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // The form arm reads the active player's form byte at the call (`0x4e558b`). let form = super::stat_read::read_self(lua, |own| own.shapeshift_form()); let state = state_of(lua, action); - let model = lua.app_data_ref::().expect("model app_data"); - let open = model.open_professions(); let view = CastView { form, - ..model.cast_view + ..cast_view(lua) }; + let open = lua + .app_data_ref::() + .expect("model app_data") + .open_professions(); Ok(match state { Some(s) if s.is_current(&view, open) => Value::Integer(1), _ => Value::Nil, diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 30c471f9a..70843f85b 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -1177,7 +1177,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, (bag, slot): (Value, Value)| { // The targeting and base-mode gates run before either argument is read // (`0x4fa469`-`0x4fa47a`). - if super::merchant::sell_cursor_shut(&lua.app_data_ref::().expect("model")) { + if super::merchant::sell_cursor_shut(lua) { return Ok(()); } // `is-number` on args 1 and 2 else `Usage: ShowContainerSellCursor(index, slot)` diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 9e2faad78..6a5848b01 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -831,12 +831,9 @@ pub(crate) fn split_container_item( equip_slots: s.equip_slots.clone(), } }; - let targeting = verb(lua, |model| { - clear_cursor(model); - model.spell_targeting - }); + verb(lua, clear_cursor); // `StopTargeting` ends an armed spell's targeting, as `SpellStopTargeting` does. - if targeting { + if super::action::spell_targeting(lua) { super::calls::submit(lua, super::ScriptCall::SpellStopTargeting)?; } pick_up_item(lua, item); diff --git a/crates/benilla-ui/src/script/inventory_verbs.rs b/crates/benilla-ui/src/script/inventory_verbs.rs index 66a7b145b..2eeb43cfa 100644 --- a/crates/benilla-ui/src/script/inventory_verbs.rs +++ b/crates/benilla-ui/src/script/inventory_verbs.rs @@ -43,9 +43,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "ShowInventorySellCursor", lua.create_function(|lua, slot: Value| { - if super::merchant::sell_cursor_shut( - &lua.app_data_ref::().expect("model app_data"), - ) { + if super::merchant::sell_cursor_shut(lua) { return Ok(()); } let slot0 = inventory_slot_arg( diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index 9312063d6..33c2d00d0 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -730,7 +730,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // its events running first. { let model = lua.app_data_ref::().expect("model app_data"); - if model.spell_targeting || !head(lua, &model).can_repair { + if super::action::spell_targeting(lua) || !head(lua, &model).can_repair { return Ok(()); } } @@ -771,7 +771,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "ShowMerchantSellCursor", lua.create_function(|lua, index: Value| { // The targeting and base-mode gates come first (`0x4fbab7`-`0x4fbacc`). - if sell_cursor_shut(&lua.app_data_ref::().expect("model app_data")) { + if sell_cursor_shut(lua) { return Ok(()); } // `is-number` else `Usage: ShowMerchantSellCursor(index)` (`0x84dd14`, `0x4fbadf`). @@ -786,7 +786,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "ShowBuybackSellCursor", lua.create_function(|lua, index: Value| { // The targeting and base-mode gates come first (`0x4fbbb9`-`0x4fbbce`). - if sell_cursor_shut(&lua.app_data_ref::().expect("model app_data")) { + if sell_cursor_shut(lua) { return Ok(()); } // `is-number` else `Usage: ShowBuybackSellCursor(index)` (`0x84dd3c`, `0x4fbbe1`). @@ -804,8 +804,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { /// The gates every sell-cursor verb opens with, ahead of its argument reads: `IsSpellTargeting` /// (`0x6e48a0`), then the base cursor mode (`0x523d10`) must be 1, which the repair cursor moves /// (`0x4fbce3`). Either failing returns quietly; each is sticky, so a Buy would stamp it out. -pub(super) fn sell_cursor_shut(model: &Model) -> bool { - model.spell_targeting || model.repair_mode +pub(super) fn sell_cursor_shut(lua: &Lua) -> bool { + super::action::spell_targeting(lua) + || lua + .app_data_ref::() + .expect("model app_data") + .repair_mode } /// Arm the vendor hover cursor from a row's price, read at the call: Buy(3) if the player's coinage diff --git a/crates/benilla-ui/src/script/minimap.rs b/crates/benilla-ui/src/script/minimap.rs index 6d8b3abf1..de0e10a7f 100644 --- a/crates/benilla-ui/src/script/minimap.rs +++ b/crates/benilla-ui/src/script/minimap.rs @@ -477,8 +477,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } let (world, ground) = { let model = lua.app_data_ref::().expect("model app_data"); - let ground = - model.spell_targeting && super::cursor::targeting_wants(lua, &model).location; + let ground = super::action::spell_targeting(lua) + && super::cursor::targeting_wants(lua, &model).location; let d = 2.0 * f64::from(model.minimap.radius_yd()); // Each fraction times the diameter is stored as a float (`0x4eee2e`, `0x4eee3d`). let (second, first) = ( diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 7b3c980e7..aac1f19e4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -236,7 +236,7 @@ mod zone_text; pub use action::{ macro_slot_id, macro_slot_word, ActionAttackFaceNow, ActionBarCall, ActionConsumableNow, ActionCountNow, ActionSlot, ActionState, ActionStateNow, ActionTextureNow, ActionUsableNow, - ActionUse, CastView, OpenProfessions, Profession, WearsItem, + ActionUse, CastView, CastViewNow, OpenProfessions, Profession, WearsItem, }; pub use addon::{addon_load_context, AddOnInfo, AddonLoad, AddonReply, AddonSecurity}; pub use addon_enable::{AddonStateFiles, EnableHash, EnableStore}; diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 33c197588..04457abdc 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -257,8 +257,9 @@ impl super::UiScript { .is_empty() } - /// Whether the spell-targeting cursor is up, for `SpellIsTargeting()` and - /// `SpellStopTargeting()`; pushed each frame before the input pass runs the ESC chain. Arming + /// Whether the spell-targeting cursor is up, for `SpellIsTargeting()`, `SpellStopTargeting()` + /// and the other tests of the word with no host to ask ([`super::CastViewNow`]); pushed each + /// frame before the input pass runs the ESC chain. Arming /// ends repair mode, as `HideRepairCursor` does: the targeting arm writes the Cast base mode /// over Repair (`0x6e50b0`), and its end restores Point (`0x6e49f5`, `0x6e554c`), never Repair. pub fn set_spell_targeting(&mut self, targeting: bool) { @@ -827,11 +828,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let may_stop = protected::gate(lua, Tag::CAST); // `0x6e6e80` reads only the auto-repeat key `0xceac30` and the in-flight id // `0xceca88`, which is 0 mid-channel, as the call that cast last left them. - let stoppable = lua - .app_data_ref::() - .expect("model app_data") - .cast_view - .stoppable(); + let stoppable = super::action::cast_view(lua).stoppable(); if may_stop && stoppable { super::calls::submit(lua, ScriptCall::SpellStopCasting)?; Ok(Value::Integer(1)) @@ -844,10 +841,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // SpellIsTargeting() (`0x6e6cd0`): 1 while the targeting cursor is up, else nil. g.set( "SpellIsTargeting", - lua.create_function(|lua, ()| { - let model = lua.app_data_ref::().expect("model app_data"); - Ok(flag(model.spell_targeting)) - })?, + lua.create_function(|lua, ()| Ok(flag(super::action::spell_targeting(lua))))?, )?; // SpellCanTargetUnit("unit") (`0x6e6d00`): the argument is a string or a number (`0x6e6d0e`, @@ -885,11 +879,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, ()| { // The gate's tag 2 opens the function (`0x6e6e38`); a refusal answers nil (`0x6e6e69`). let may_stop = protected::gate(lua, Tag::CAST); - let targeting = lua - .app_data_ref::() - .expect("model app_data") - .spell_targeting; - if may_stop && targeting { + if may_stop && super::action::spell_targeting(lua) { super::calls::submit(lua, ScriptCall::SpellStopTargeting)?; Ok(Value::Integer(1)) } else { diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 5df760485..9f7cc20ac 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -1138,11 +1138,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { if !crate::script::protected::gate(lua, crate::script::protected::Tag::CAST) { return Ok(()); } - if !lua - .app_data_ref::() - .expect("model app_data") - .spell_targeting - { + if !super::super::action::spell_targeting(lua) { return Ok(()); } check_unit_token(&Some(token.clone()))?; diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 6e9563713..5ca2a5e8b 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -278,7 +278,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### spell - An out-of-range byte drops the packet, because the reference's unchecked write overruns the table (`(64, 19)` lands on the class family at `0xcecaac`). ([`src/spell/mods.rs:131`](../crates/benilla-app/src/spell/mods.rs#L131)) -- The fallback is the packet's target guid, started only when the validator keeps it (`on_spell_go`), and with none there is no start, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1478`](../crates/benilla-app/src/spell/net.rs#L1478)) +- The fallback is the packet's target guid, started only when the validator keeps it (`on_spell_go`), and with none there is no start, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1479`](../crates/benilla-app/src/spell/net.rs#L1479)) - A range failure leaves targeting armed and working. The reference's report cancels the cast (`0x6e2218` → `0x6e4940`): its line, `SPELLCAST_FAILED` and the tradeskill disarm, which benilla keeps; with the word the bind emptied it also sends `CMSG_CANCEL_CAST` for the unsent spell (`0x6e4970`) and clears the caster, then restores the pre-click word (`0x6e6096`), so every later click fails the caster test (`0x6e5b93`) and the cursor is stranded until a cancel. ([`src/spell/targeting/mod.rs:658`](../crates/benilla-app/src/spell/targeting/mod.rs#L658)) #### target @@ -299,8 +299,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The player's `0x5e` with no word falls back to the spell's own `RequiresSpellFocus`, which the reference never reads here, because it is the id vmangos sends (`Spell.cpp:4426`). `0x5d` has no such stand-in: vmangos takes its area from its own `spell_area` table (`Spell.cpp:4429`), which `Spell.dbc` does not carry. ([`src/ui_action/cast_fail.rs:311`](../crates/benilla-app/src/ui_action/cast_fail.rs#L311)) - A reason past the table prints its hex code, so an unknown reason is visible. ([`src/ui_action/cast_fail.rs:415`](../crates/benilla-app/src/ui_action/cast_fail.rs#L415)) -- A declined arm's template loses its tokens ("Missing reagent: %s" reads "Missing reagent") where the reference shows the literal `%s`, because a raw token reads as broken. ([`src/ui_action/cast_fail.rs:524`](../crates/benilla-app/src/ui_action/cast_fail.rs#L524)) -- An empty chat line is dropped, where the reference adds a blank one (`0x49a870` refuses only a null text, `0x49a881`), because a blank line is worse than none; only a string an addon emptied, or a chain missing a stock key, reaches it. ([`src/ui_action/errors.rs:587`](../crates/benilla-app/src/ui_action/errors.rs#L587)) +- A declined arm's template loses its tokens ("Missing reagent: %s" reads "Missing reagent") where the reference shows the literal `%s`, because a raw token reads as broken. ([`src/ui_action/cast_fail.rs:534`](../crates/benilla-app/src/ui_action/cast_fail.rs#L534)) +- An empty chat line is dropped, where the reference adds a blank one (`0x49a870` refuses only a null text, `0x49a881`), because a blank line is worse than none; only a string an addon emptied, or a chain missing a stock key, reaches it. ([`src/ui_action/errors.rs:588`](../crates/benilla-app/src/ui_action/errors.rs#L588)) - The book's and the bar's login packets run this over every staged spell word, where the reference re-points slots only at `SMSG_SUPERCEDED_SPELL`, old rank to new (`0x4e5e60`, `super::spell_slots`), because vmangos never rewrites stored slots on a rank-up (`Player::AddSpell`), drops superseded ranks from the book (`Player.cpp:3463`) and sends no supersede while loading (`Player.cpp:3704`), so a stored slot can name a spell the character no longer knows. ([`src/ui_action/ranks.rs:3`](../crates/benilla-app/src/ui_action/ranks.rs#L3)) #### ui_aura @@ -723,7 +723,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:589`](../crates/benilla-ui/src/script/action.rs#L589)) +- 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:621`](../crates/benilla-ui/src/script/action.rs#L621)) - The files the handler writes beside the install (`.pub` with the key, `.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)) - Holding the file, so the shutdown write keeps it. ([`src/script/addon.rs:787`](../crates/benilla-ui/src/script/addon.rs#L787)) - 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)) @@ -750,8 +750,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:255`](../crates/benilla-ui/src/script/craft.rs#L255)) - The reference reads past the table unbounded (`0x4f6c90`), so type 5 would answer "CloseCraft"; no shipped opener carries a type past 3, and benilla answers nil rather than read past it. ([`src/script/craft.rs:926`](../crates/benilla-ui/src/script/craft.rs#L926)) - On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:744`](../crates/benilla-ui/src/script/cursor.rs#L744)) -- Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:953`](../crates/benilla-ui/src/script/cursor.rs#L953)) -- Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/cursor.rs:1249`](../crates/benilla-ui/src/script/cursor.rs#L1249)) +- Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:950`](../crates/benilla-ui/src/script/cursor.rs#L950)) +- Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/cursor.rs:1246`](../crates/benilla-ui/src/script/cursor.rs#L1246)) - An auto-store into a bank item or keyring slot sends nothing, because no container id names it and no stock caller passes one; the reference sends it and the server refuses. ([`src/script/cursor/bag_verbs.rs:26`](../crates/benilla-ui/src/script/cursor/bag_verbs.rs#L26)) - A row index the list no longer holds buys nothing and drops, where this site reads through a null row (`0x4c7e9a`, `0x4c7ea5`). ([`src/script/cursor/bag_verbs.rs:132`](../crates/benilla-ui/src/script/cursor/bag_verbs.rs#L132)) - Slot 11 is refused, because the reference's gate (`cmp esi,0xa; jbe`) admits it; `0x4bce00` checks its bound (`0x4bce14`) but `0x4bc9a0` does not, so it writes one dword past the array (`0x4bcad9`), and the autocast toggle (`0x4bcbb0`) reads that word (`[0xb71438+0x28]`) with no bounds check. ([`src/script/cursor/pet.rs:20`](../crates/benilla-ui/src/script/cursor/pet.rs#L20))