The cast getters read live; a refused reagent line shows at its answer (#2818)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-30 00:06:35 +07:00
committed by GitHub
parent 7e66ef52a0
commit 24cd215ba6
30 changed files with 483 additions and 274 deletions
+8 -6
View File
@@ -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()
}
@@ -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:<text>`.
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<u32>) {
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<i64>, Option<i64>)>(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<i64>, Option<i64>)>(true, chunk),
(None, None)
);
assert_eq!(
c.eval::<(Option<i64>, Option<i64>)>(false, chunk),
(Some(1), None),
"the VM's copy"
);
c.press("Stopped = SpellStopCasting()");
assert_eq!(c.eval::<Option<i64>>(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::<crate::spell::SpellTargeting>().enter(
HEAL,
crate::spell::CastCommit::Spell,
0x0002,
);
world.resource_mut::<crate::spell::AutoRepeatActive>().0 = Some(HEAL);
let chunk = "return SpellIsTargeting(), IsAutoRepeatAction(1)";
assert_eq!(
c.eval::<(Option<i64>, Option<i64>)>(true, chunk),
(Some(1), Some(1))
);
assert_eq!(
c.eval::<(Option<i64>, Option<i64>)>(false, chunk),
(None, None),
"the VM's copy"
);
c.press("Stopped = SpellStopTargeting()");
assert_eq!(c.eval::<Option<i64>>(false, "return Stopped"), Some(1));
assert!(!c
.app
.world()
.resource::<crate::spell::SpellTargeting>()
.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::<crate::items::Items>()
.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::<crate::items::Items>()
.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"]);
}
+9 -15
View File
@@ -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",
@@ -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` → \
+3 -6
View File
@@ -519,15 +519,12 @@ fn cast_stops(_: In<SessionEvent>, mut reports: ResMut<crate::ui_cast::CastRepor
reports.bar(crate::ui_cast::CastBarEdge::Stop);
}
/// A client with the cast reports' lane too, and what their readying reads.
/// A client with the cast reports' lane too.
fn cast_client(handlers: impl FnOnce(&mut App)) -> App {
let mut app = client(|app| {
client(|app| {
app.add_plugins(crate::ui_cast::UiCastPlugin);
handlers(app);
});
app.world_mut()
.init_resource::<crate::ui_action::CastFailRedisplays>();
app
})
}
/// A cast's report fires inside its packet's handler (`SPELLCAST_STOP` from `SMSG_CAST_RESULT`,
+8
View File
@@ -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::<crate::ui_cast::CastReports>() {
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
+4 -3
View File
@@ -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()
);
@@ -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<crate::spell::targeting::TargetingLive<'static, 'static>>,
/// 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<crate::ui_cast::CastLive<'static, 'static>>,
#[cfg(test)]
probe: tests::ProbeLive<'static>,
}
+2 -2
View File
@@ -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()
+31 -15
View File
@@ -830,7 +830,6 @@ mod tests {
.add_message::<crate::net::FieldChanged>()
.init_resource::<CastReports>()
.init_resource::<crate::items::Items>()
.init_resource::<crate::ui_action::CastFailRedisplays>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<PendingCast>()
@@ -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<i64> {
app.world_mut()
.run_system_once(|mut script: NonSendMut<UiScript>, 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::<Option<i64>>("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<CallTail> {
if call == ScriptCall::SpellStopCasting {
let mut cancel = SystemState::<SelfCancel>::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::<crate::ui_cast::CastLive>::new(self.0);
live.get(self.0).answer(ask)
}
}
let world = app.world_mut();
let mut script = world.remove_non_send_resource::<UiScript>().expect("a VM");
let answer = script.hosted(&mut Host(world), |s| {
s.eval::<Option<i64>>("return SpellStopCasting()")
});
world.insert_non_send_resource(script);
answer.unwrap()
}
#[test]
+2 -1
View File
@@ -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
-1
View File
@@ -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()
@@ -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),
+31 -60
View File
@@ -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<CastFail>);
/// 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<Res<'w, crate::ui_items::ItemSubClasses>>,
names: FailNameTables<'w>,
self_q: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
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<ResolvedFail> {
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<Shown> {
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<CastFailLine> {
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;
}
}
}
}
+4 -3
View File
@@ -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)
}
}
}
@@ -188,8 +188,8 @@ fn a_notice_under_the_loading_cover_fires_at_the_reveal() {
assert!(app.world().resource::<UiErrorKeys>().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::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<super::CastFailRedisplays>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::spell::PendingCast>()
+1 -2
View File
@@ -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::<ReactiveSpells>()
.init_resource::<ActionSlots>()
.init_resource::<LearnedAbilities>()
.init_resource::<CastFailRedisplays>()
.init_resource::<ClickCasts>()
.add_systems(Startup, load_spells.after(AssetSet::Open))
.add_systems(Startup, load_spell_mechanics.after(AssetSet::Open))
+13 -65
View File
@@ -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::<String>(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<Ready> {
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<Ready> {
@@ -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<ResMut<'static, CastViews>>,
)>,
) {
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<CastReports>) {
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),
+45 -25
View File
@@ -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<CastView>,
/// 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<CastView>,
/// 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<CastView>,
/// The view the VM holds.
shown: Option<CastView>,
@@ -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<Res<'w, CastViews>>,
}
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::<CastViews>() 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<CastViews>) {
};
}
/// 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) {
+2 -5
View File
@@ -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),
+44 -11
View File
@@ -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<u32>,
}
/// 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::<Model>()
.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::<Model>()
.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::<Model>().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::<Model>().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::<Model>()
.expect("model app_data")
.open_professions();
Ok(match state {
Some(s) if s.is_current(&view, open) => Value::Integer(1),
_ => Value::Nil,
+1 -1
View File
@@ -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::<Model>().expect("model")) {
if super::merchant::sell_cursor_shut(lua) {
return Ok(());
}
// `is-number` on args 1 and 2 else `Usage: ShowContainerSellCursor(index, slot)`
+2 -5
View File
@@ -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);
@@ -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::<Model>().expect("model app_data"),
) {
if super::merchant::sell_cursor_shut(lua) {
return Ok(());
}
let slot0 = inventory_slot_arg(
+9 -5
View File
@@ -730,7 +730,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// its events running first.
{
let model = lua.app_data_ref::<Model>().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::<Model>().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::<Model>().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::<Model>()
.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
+2 -2
View File
@@ -477,8 +477,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
let (world, ground) = {
let model = lua.app_data_ref::<Model>().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) = (
+1 -1
View File
@@ -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};
+6 -16
View File
@@ -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::<Model>()
.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::<Model>().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::<Model>()
.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 {
@@ -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::<Model>()
.expect("model app_data")
.spell_targeting
{
if !super::super::action::spell_targeting(lua) {
return Ok(());
}
check_unit_token(&Some(token.clone()))?;
+6 -6
View File
@@ -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 (`<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))
- 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))