He re-tested and it still never casts Slam, so I drove the whole thing rather
than the conditional: `UseAction` on a macro slot → `run_macro` fires
`EXECUTE_CHAT_LINE` per line (`0x4f14e0`) → the stock ChatFrame sends it through
its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
`SlashCmdList["CAST"]` → CleveRoids' hook → `GetParsedMsg` → `DoWithConditionals`
→ `Keywords.noslamclip` → `ValidateNoSlamClip` → `CastSpellByName`. On his own
interface, with his own 2.70 s weapon and Battle Stance, the line casts **Slam**
at the top of a swing and **Heroic Strike** 1.6 s in — which is the assertion the
old test never made, and the reason "the conditional answers true" meant nothing.
Three things came out of the drive.
**The load-time latch is worse than I wrote this morning, and it is what he is
seeing.** `GetSlamWindowPercent` reads SP_SwingTimer's `st_timerMax` cache, not
`UnitAttackSpeed`, so the `ADDON_LOADED` latch decides the window for the whole
first fight: the stand-in gives (2.0 - 1.5)/2.0 = **25%** against his weapon's
44%, while `st_timer` is still 0 so the swing reads **100% elapsed** — and
`[noslamclip]` is false with nothing said about it. Before the stand-in it was
false *and* printed "requires SP_SwingTimer" once; the fix traded a visible
failure for a silent one. Both states are pinned in the test now.
So the sheet is **seeded before the addon walk**, beside the roster record and
unit snapshot that are already seeded there for the same reason
(`ui_char::seed_combat_stats`). A `/reload` — and anyone mid-fight — now latches
the real weapon. A fresh login still has no self store (the create block has not
arrived, which is why the record comes off the roster), so the stand-in stands
there and heals on the first swing.
**And the stand-in now says so in the log.** One line per VM per token, through
the channel `/errors` lists: a combat-stats verb answering for "player" before
anything pushed that unit's sheet. One such read is expected at load; what the
line is for is the case where it never stops, which no test of the verb can see
because every one of them seats the sheet first. `UnitAttackSpeed` reaching Lua
with the store's own `UNIT_FIELD_BASEATTACKTIME` is now asserted through the real
`feed_char`, which is the test that was missing underneath all of this.
The dual-wield road is pinned too, because it is the one shape that can strand
the main-hand timer: `CheckDamageSource` returns "MAIN" on line one for a
two-hander and never runs its arithmetic, but dual-wielding it weighs the hit
against `UnitDamage`'s ranges scaled by the target's armour — and
`UnitArmor("target")` is 0 on any 1.12 client, ours included, because another
unit's resistances are not public. Every hit then reads UNKNOWN and falls to
`ClosestSwing == true or HeroicStrikeQueued()`, whose first half compares against
a *function* and whose second is true whenever this very macro has queued a
Heroic Strike — and `ResetTimer(true)` refills the **off-hand** timer while
`st_timer` runs to 0, where the elapsed read is 100 for good. That loop is the
addon's own and reproduces on any client; what we must not do is enter it when
the player is two-handed, which is `has_offhand` alone, and his combat log says
two-handed: 2.70, 2.71, 2.72, 2.68 between consecutive hits, with no 0.2 s
off-hand pairing anywhere.
189 failures, unchanged, no regressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1916 lines
80 KiB
Rust
1916 lines
80 KiB
Rust
//! The character-window feed: the [`UnitCombatStats`] and `InventorySlots` snapshots the paper
|
|
//! doll's Lua bindings read, built from our avatar's descriptor and item stores; the events that
|
|
//! repaint it; and the booth's yaw from the stock `CharacterModelFrame`. [`unit_combat_stats`] is
|
|
//! the half any unit shares: a creature has no player fields, so those read absent and keep their
|
|
//! defaults.
|
|
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
|
|
use benilla_ui::script::{
|
|
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ScriptValue, SkillEntry,
|
|
SkillsState, UiScript, UnitCombatStats, BANK_BAG_SLOT_COUNT, EQUIPMENT_BAG, SKILL_DEFENSE,
|
|
SKILL_UNARMED,
|
|
};
|
|
use benilla_ui::strings::Arg;
|
|
|
|
use crate::entities::ItemDisplays;
|
|
use crate::items::Items;
|
|
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
|
|
use crate::pending_item_ops::PendingItemOps;
|
|
use crate::portrait::PaperDollBooth;
|
|
use crate::ui_items::{find_equip_slot, item_link};
|
|
use crate::ui_script::{gate, UiFeed, UiInput};
|
|
|
|
/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): main hand, off hand, ranged.
|
|
const SLOT_MAIN_HAND: u8 = 15;
|
|
const SLOT_OFF_HAND: u8 = 16;
|
|
const SLOT_RANGED: u8 = 17;
|
|
/// Item class 2, weapon (vmangos `ItemPrototype.h:139`).
|
|
const ITEM_CLASS_WEAPON: u32 = 2;
|
|
/// Weapon subclass 19, wand (`ItemPrototype.h:211`), for `HasWandEquipped`.
|
|
const SUBCLASS_WAND: u32 = 19;
|
|
|
|
/// An f32 snapshot field as a diff key, quantized to 1/32 so equal wire values compare equal.
|
|
fn fixed(v: f32) -> i64 {
|
|
(v * 32.0).round() as i64
|
|
}
|
|
|
|
/// The last snapshots pushed, so events fire on transitions only; held per VM
|
|
/// ([`crate::ui_script::VmMemo`]), so a `/reload` re-pushes and re-fires as a login does.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct CharFeedState {
|
|
vm: crate::ui_script::VmMemo<CharFeedMemo>,
|
|
}
|
|
|
|
/// The per-VM half of [`CharFeedState`]: the transition-diff bases.
|
|
#[derive(Default)]
|
|
struct CharFeedMemo {
|
|
last_stats: Option<UnitCombatStats>,
|
|
last_inv: Option<InventorySlots>,
|
|
last_bank_bags: Option<BankBagSlots>,
|
|
/// The bag guid in each bank bag slot, `PLAYERBANKSLOTS_CHANGED`'s discriminator: the pushed
|
|
/// view cannot tell two copies of one bag apart, and the reference fires when they swap.
|
|
last_bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT],
|
|
/// Counters for the stores whose lazy resolves poison `is_changed` (item templates, creator
|
|
/// names); item objects are entities, watched through [`crate::items::ItemChanges`].
|
|
items_templates: gate::Watch,
|
|
names_generation: gate::Watch,
|
|
/// `ItemChanges::countdown_steps`: one step per displayed-second change, the final elapse too.
|
|
enchant_deadlines: gate::Watch,
|
|
/// The price discount `0x4faf30` takes off every per-slot repair cost, as its f64 bits: it is
|
|
/// not a descriptor field of ours, so no `Changed<>` sees it move. Opening a vendor the player
|
|
/// is Honored with drops every equipped slot's quote by 10% in one step, and this is the only
|
|
/// input that reports it.
|
|
repair_discount: gate::Watch,
|
|
/// `PendingItemOps::epoch`, beside `!is_empty()`: that holds the gate open while an op is in
|
|
/// flight, this catches the frame a lock clears with no object moving
|
|
/// (`SMSG_INVENTORY_CHANGE_FAILURE`).
|
|
pending_epoch: gate::Watch,
|
|
/// `Cooldowns::feed_epoch`: an equipped slot's use cooldown is in these views now, and no
|
|
/// descriptor moves when one is armed or expires. The epoch is the right input rather than a
|
|
/// per-frame hold-open, because the triple itself is absolute — a running cooldown changes
|
|
/// nothing until it is armed, re-armed or pruned.
|
|
cooldowns_epoch: gate::Watch,
|
|
}
|
|
|
|
/// The three inputs an equipped slot's **use cooldown** needs, as one [`SystemParam`]: the record
|
|
/// list, the spell catalog its query keys on, and the frame's one clock pair.
|
|
///
|
|
/// A group rather than three parameters because Bevy's `IntoSystem` stops at sixteen, and
|
|
/// [`feed_char`] was already at that ceiling — which is a fair signal that its parameters wanted
|
|
/// grouping by what they are *for* rather than listing one by one.
|
|
#[derive(bevy::ecs::system::SystemParam)]
|
|
pub(crate) struct ItemCooldownInputs<'w> {
|
|
cooldowns: Option<Res<'w, crate::spell::Cooldowns>>,
|
|
spells: Option<Res<'w, crate::ui_action::Spells>>,
|
|
clock: Res<'w, crate::ui_script::UiClock>,
|
|
}
|
|
|
|
impl ItemCooldownInputs<'_> {
|
|
/// The tuple `slot_view` takes, in one place so the doll and the bank bags pass the same
|
|
/// thing.
|
|
fn parts(
|
|
&self,
|
|
) -> (
|
|
Option<&crate::spell::Cooldowns>,
|
|
Option<&crate::ui_action::Spells>,
|
|
std::time::Instant,
|
|
f64,
|
|
) {
|
|
(
|
|
self.cooldowns.as_deref(),
|
|
self.spells.as_deref(),
|
|
self.clock.anchor,
|
|
self.clock.ui_now,
|
|
)
|
|
}
|
|
|
|
/// `Cooldowns::feed_epoch`, or 0 with no list: the gate input that opens this feed on the
|
|
/// frame a trinket's cooldown is armed or pruned.
|
|
fn epoch(&self) -> u64 {
|
|
self.cooldowns.as_ref().map_or(0, |c| c.feed_epoch())
|
|
}
|
|
}
|
|
|
|
/// The three inputs a slot's **repair cost** needs beyond the item itself, as one [`SystemParam`]
|
|
/// for the same reason: the price tables, the open vendor (whose guid is the only thing that turns
|
|
/// the discount on) and the reaction catalogs the discount reads. The merchant window's own feeds
|
|
/// take exactly the same three.
|
|
#[derive(bevy::ecs::system::SystemParam)]
|
|
pub(crate) struct RepairInputs<'w> {
|
|
tables: Option<Res<'w, crate::ui_merchant::RepairTables>>,
|
|
merchant: Res<'w, crate::ui_merchant::MerchantOpen>,
|
|
reactions: crate::target::ReactionInputs<'w>,
|
|
}
|
|
|
|
/// The character-window feed; its bindings are `benilla-ui`'s `char_stats`.
|
|
pub(crate) struct UiCharPlugin;
|
|
|
|
impl Plugin for UiCharPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<CharFeedState>().add_systems(
|
|
Update,
|
|
(
|
|
// Before `feed_containers`: a `BAG_UPDATE` handler may read the bag item's own
|
|
// row (`GetInventoryItemLink("player", ContainerIDToInventoryID(bag))`), so the
|
|
// inventory push lands first.
|
|
feed_char
|
|
.before(crate::ui_items::feed::feed_containers)
|
|
// After the vendor's range-close, which is the other writer of the
|
|
// `MerchantOpen` this reads for its repair discount: walking out of range has
|
|
// to put the list price back in the same frame the window shuts, exactly as
|
|
// the merchant plugin orders that close ahead of its own feed.
|
|
.after(
|
|
crate::ui_session::close_npc_session_out_of_range::<
|
|
crate::ui_merchant::MerchantOpen,
|
|
>,
|
|
)
|
|
.in_set(UiFeed),
|
|
watch_skill_ups.in_set(UiFeed),
|
|
feed_skills.in_set(UiFeed),
|
|
drain_skill_abandons.after(UiInput),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The skill block [`watch_skill_ups`] last announced: the self guid and its `skillId → value`
|
|
/// map, the diff base for the chat lines and the event.
|
|
type SkillBlock = Option<(u64, std::collections::HashMap<u16, u16>)>;
|
|
|
|
/// One skill-up chat line, `ERR_SKILL_UP_SI` or `ERR_SKILL_GAINED_S`, pushed as `CHAT_MSG_SKILL`.
|
|
/// The server sends no skill-up message; the reference's watcher on the skill value words
|
|
/// (`0x5de180`) prints these through `0x496720`, silent for a line whose `SkillRaceClassInfo`
|
|
/// flags carry `0x402` or that no row admits for this race and class (`0x5de352`), so a level-up
|
|
/// prints none. Not `ui_action::show_messages`: that sink sends every chat row as
|
|
/// `CHAT_MSG_SYSTEM`, and these two ask for `CHAT_MSG_SKILL` (23).
|
|
fn skill_line(script: &UiScript, chat: &mut crate::ui_chat::ChatLog, key: &str, args: &[Arg<'_>]) {
|
|
let Some(template) = script
|
|
.lua()
|
|
.globals()
|
|
.get::<String>(key)
|
|
.ok()
|
|
.filter(|t| !t.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
chat.push_event(crate::ui_chat::ChatEvent::text_only(
|
|
crate::ui_chat::ChatEventKind::Skill,
|
|
benilla_ui::strings::fill(&template, args),
|
|
));
|
|
}
|
|
|
|
fn watch_skill_ups(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
self_guid: Res<crate::net::SelfGuid>,
|
|
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
|
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
|
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
|
mut prev: Local<crate::ui_script::VmMemo<SkillBlock>>,
|
|
) {
|
|
let (Some(guid), Ok(store)) = (self_guid.0, self_store.single()) else {
|
|
return;
|
|
};
|
|
// The memo is per VM and a new VM re-seeds silently; with no VM nothing is diffed, or a stale
|
|
// skill-up would replay at the next login. `SKILL_LINES_CHANGED` fires here only when a line
|
|
// is added, removed or changes value; the reference fires it at the end of every skill-list
|
|
// rebuild (`0x4d2fec`), which its value, max, permanent-bonus and temporary-bonus watchers
|
|
// run (registered `0x5dda54`-`0x5ddaf0`), and from both header collapse and expand verbs.
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let prev = prev.get(&script);
|
|
let mut cur = std::collections::HashMap::new();
|
|
for i in 0..PLAYER_SKILL_SLOTS {
|
|
if let Some(s) = store.0.player_skill(i) {
|
|
if s.skill_id != 0 {
|
|
cur.insert(s.skill_id, s.value);
|
|
}
|
|
}
|
|
}
|
|
match prev.as_ref() {
|
|
// First fill: seed silently, no lines; the event fires once.
|
|
None => {
|
|
if cur.is_empty() {
|
|
return;
|
|
}
|
|
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
|
*prev = Some((guid, cur));
|
|
}
|
|
Some((prev_guid, _)) if *prev_guid != guid => {
|
|
// A different character: re-seed, never diff across characters.
|
|
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
|
*prev = Some((guid, cur));
|
|
}
|
|
Some((_, prev_map)) => {
|
|
if *prev_map == cur {
|
|
return;
|
|
}
|
|
// The `0x402` gate reads the player's own race and class row, as `skills_row` does.
|
|
let (race, class) = (
|
|
store.0.unit_race().unwrap_or(0),
|
|
store.0.unit_class().unwrap_or(0),
|
|
);
|
|
for (&id, &value) in &cur {
|
|
let line_id = u32::from(id);
|
|
let name = || {
|
|
skill_lines
|
|
.as_ref()
|
|
.and_then(|s| s.catalog.line(line_id))
|
|
.map(|l| l.name.clone())
|
|
};
|
|
let announces = || {
|
|
skill_lines
|
|
.as_ref()
|
|
.is_some_and(|s| s.catalog.announces_skill_ups(line_id, race, class))
|
|
};
|
|
match prev_map.get(&id) {
|
|
// A rank-up: `ERR_SKILL_UP_SI` (catalog row 54), not `SKILL_RANK_UP`, the
|
|
// same enUS sentence with no catalog row.
|
|
Some(&old) if value > old => {
|
|
if let Some(name) = name() {
|
|
if announces() {
|
|
debug!("chat: skill-up announced ({name} {old}→{value})");
|
|
skill_line(
|
|
&script,
|
|
&mut chat,
|
|
"ERR_SKILL_UP_SI",
|
|
&[Arg::S(&name), Arg::D(i64::from(value))],
|
|
);
|
|
} else {
|
|
debug!("chat: skill-up silenced ({name} {old}→{value}, the 0x402 gate)");
|
|
}
|
|
}
|
|
}
|
|
// A new line: `ERR_SKILL_GAINED_S` (catalog row 53).
|
|
None => {
|
|
if let Some(name) = name() {
|
|
if announces() {
|
|
debug!("chat: skill-gain announced ({name} at {value})");
|
|
skill_line(
|
|
&script,
|
|
&mut chat,
|
|
"ERR_SKILL_GAINED_S",
|
|
&[Arg::S(&name)],
|
|
);
|
|
} else {
|
|
debug!(
|
|
"chat: skill-gain silenced ({name} at {value}, the 0x402 gate)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
script.fire_event("SKILL_LINES_CHANGED", vec![]);
|
|
*prev = Some((guid, cur));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The skills-pane feed: every `PLAYER_SKILL_INFO` line the reference lists, as a flat
|
|
/// [`SkillEntry`] pushed through `set_skills` on change; the engine groups, sorts and folds.
|
|
fn feed_skills(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
self_store: Query<Ref<ObjectStore>, With<SelfPlayer>>,
|
|
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
|
|
mut last: Local<crate::ui_script::VmMemo<Option<SkillsState>>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let last = last.get(&script);
|
|
let lines_moved = skill_lines.as_ref().is_some_and(|l| l.is_changed());
|
|
let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else {
|
|
return;
|
|
};
|
|
// A pure function of the descriptor and the catalog: with both still, nothing can differ.
|
|
if last.is_some() && !store.is_changed() && !lines_moved {
|
|
return;
|
|
}
|
|
let (race, class) = (
|
|
store.0.unit_race().unwrap_or(0),
|
|
store.0.unit_class().unwrap_or(0),
|
|
);
|
|
let level = store.0.unit_level().unwrap_or(0);
|
|
let mut entries = Vec::new();
|
|
for i in 0..PLAYER_SKILL_SLOTS {
|
|
let Some(slot) = store.0.player_skill(i) else {
|
|
continue;
|
|
};
|
|
if let Some(e) = skills_row(&slot, &skill_lines.catalog, race, class, level) {
|
|
entries.push(e);
|
|
}
|
|
}
|
|
let fresh = SkillsState { entries };
|
|
if last.as_ref() == Some(&fresh) {
|
|
return;
|
|
}
|
|
script.set_skills(fresh.clone());
|
|
*last = Some(fresh);
|
|
}
|
|
|
|
/// One skill slot as a Skills-tab row, or `None` where the reference's list build (`0x4d2cb0`)
|
|
/// skips it; the checks run in that build's order.
|
|
pub(crate) fn skills_row(
|
|
slot: &benilla_protocol::messages::PlayerSkillSlot,
|
|
catalog: &benilla_formats::SkillLineCatalog,
|
|
race: u8,
|
|
class: u8,
|
|
level: u32,
|
|
) -> Option<SkillEntry> {
|
|
if slot.skill_id == 0 {
|
|
return None;
|
|
}
|
|
let id = u32::from(slot.skill_id);
|
|
let line = catalog.line(id)?;
|
|
let rc = catalog.race_class(id, race, class)?;
|
|
// The category row must exist; no category filters, `Not Displayed` (12) included.
|
|
let (category_name, category_order) = catalog.category(line.category_id)?;
|
|
// Flag `0x2` hides `Dual Wield`, the racials, the mount lines and `GENERIC (DND)`.
|
|
if rc.hidden() {
|
|
return None;
|
|
}
|
|
// An untrained line shows only where the flags admit it at this level.
|
|
if slot.value == 0 && !rc.displays_untrained(level) {
|
|
return None;
|
|
}
|
|
Some(SkillEntry {
|
|
skill_id: id,
|
|
name: line.name.clone(),
|
|
value: u32::from(slot.value),
|
|
max: u32::from(slot.max),
|
|
temp_bonus: i32::from(slot.temp_bonus),
|
|
perm_bonus: i32::from(slot.perm_bonus),
|
|
min_level: rc.min_level,
|
|
cost_index: rc.cost_index,
|
|
category_id: line.category_id,
|
|
category_name: category_name.to_string(),
|
|
category_order,
|
|
description: line.description.clone(),
|
|
// The unlearnable bit and a nonzero skill step (`0x4d3953`).
|
|
abandonable: slot.step != 0 && rc.unlearnable(),
|
|
mono: rc.mono(),
|
|
})
|
|
}
|
|
|
|
/// Each queued `AbandonSkill` becomes one `CMSG_UNLEARN_SKILL`; nothing changes locally, as the
|
|
/// server's `SetSkill(id, 0, 0)` (vmangos `SkillHandler.cpp:69`) returns as a skill-field update.
|
|
fn drain_skill_abandons(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
for skill_id in script.take_skill_abandons() {
|
|
debug!("ui_char: abandon skill line {skill_id}");
|
|
let _ = commands.0.send(ClientCommand::UnlearnSkill { skill_id });
|
|
}
|
|
}
|
|
|
|
/// The item entry in an equipped slot; `None` for an empty slot or an item not yet streamed.
|
|
fn slot_entry(store: &ObjectStore, objects: &Objects, slot0: u8) -> Option<u32> {
|
|
let guid = store.0.player_inv_slot(slot0)?;
|
|
objects.object(guid)?.object_entry()
|
|
}
|
|
|
|
/// The weapon-skill line for `slot0`: vmangos's subclass table (`Item.cpp:700`) for a weapon,
|
|
/// Unarmed (162) for anything else.
|
|
fn weapon_skill_id(
|
|
store: &ObjectStore,
|
|
objects: &Objects,
|
|
items: &Items,
|
|
commands: &NetCommands,
|
|
slot0: u8,
|
|
) -> u32 {
|
|
let Some(entry) = slot_entry(store, objects, slot0) else {
|
|
return SKILL_UNARMED;
|
|
};
|
|
let Some(t) = items.template(entry, 0, commands) else {
|
|
return SKILL_UNARMED; // in flight: refined when the template lands
|
|
};
|
|
if t.class != ITEM_CLASS_WEAPON {
|
|
return SKILL_UNARMED;
|
|
}
|
|
weapon_subclass_skill(t.subclass).unwrap_or(SKILL_UNARMED)
|
|
}
|
|
|
|
/// A skill's `(value + permanent bonus, temporary bonus)`, split as the reference's `0x5ea460`
|
|
/// splits it; value 0 skips the permanent add (`0x5ea4b4`). Signed: a malus can exceed the value.
|
|
fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) {
|
|
for i in 0..PLAYER_SKILL_SLOTS {
|
|
if let Some(s) = store.0.player_skill(i) {
|
|
if u32::from(s.skill_id) == skill_id {
|
|
let value = i32::from(s.value);
|
|
let base = if value == 0 {
|
|
0
|
|
} else {
|
|
value + i32::from(s.perm_bonus)
|
|
};
|
|
return (base, i32::from(s.temp_bonus));
|
|
}
|
|
}
|
|
}
|
|
(0, 0)
|
|
}
|
|
|
|
/// A swing time in ms, with 0 or absent read as not yet streamed: the vanilla 2000, never zero,
|
|
/// as the reference divides damage by it. The same stand-in
|
|
/// [`UnitCombatStats::default`](benilla_ui::script::UnitCombatStats::default) uses before this
|
|
/// feed has pushed at all, so the two answers agree across the push.
|
|
fn base_swing(streamed: Option<u32>) -> u32 {
|
|
streamed
|
|
.filter(|&ms| ms != 0)
|
|
.unwrap_or(benilla_ui::script::VANILLA_SWING_MS)
|
|
}
|
|
|
|
pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
|
|
let f = &store.0;
|
|
|
|
let mut stats = [0i32; 5];
|
|
let mut stat_pos = [0i32; 5];
|
|
let mut stat_neg = [0i32; 5];
|
|
for i in 0..5u8 {
|
|
stats[usize::from(i)] = f.unit_stat(i).unwrap_or(0) as i32;
|
|
// The buff-split arrays are integers on the wire, not floats.
|
|
stat_pos[usize::from(i)] = f.player_posstat(i).unwrap_or(0);
|
|
stat_neg[usize::from(i)] = f.player_negstat(i).unwrap_or(0);
|
|
}
|
|
let mut resistances = [0i32; 7];
|
|
let mut resistance_pos = [0i32; 7];
|
|
let mut resistance_neg = [0i32; 7];
|
|
let mut damage_bonus_pos = [0i32; 7];
|
|
let mut damage_bonus_neg = [0i32; 7];
|
|
for i in 0..7u8 {
|
|
resistances[usize::from(i)] = f.unit_resistance(i).unwrap_or(0);
|
|
resistance_pos[usize::from(i)] = f.player_resistance_buff_pos(i).unwrap_or(0);
|
|
resistance_neg[usize::from(i)] = f.player_resistance_buff_neg(i).unwrap_or(0);
|
|
damage_bonus_pos[usize::from(i)] = f.player_mod_damage_done_pos(i).unwrap_or(0);
|
|
damage_bonus_neg[usize::from(i)] = f.player_mod_damage_done_neg(i).unwrap_or(0);
|
|
}
|
|
|
|
let (ap_pos, ap_neg) = f.unit_attack_power_mods();
|
|
let (rap_pos, rap_neg) = f.unit_ranged_attack_power_mods();
|
|
|
|
UnitCombatStats {
|
|
stats,
|
|
stat_pos,
|
|
stat_neg,
|
|
resistances,
|
|
resistance_pos,
|
|
resistance_neg,
|
|
min_damage: f.unit_min_damage().unwrap_or(0.0),
|
|
max_damage: f.unit_max_damage().unwrap_or(0.0),
|
|
min_offhand_damage: f.unit_min_offhand_damage().unwrap_or(0.0),
|
|
max_offhand_damage: f.unit_max_offhand_damage().unwrap_or(0.0),
|
|
damage_bonus_pos,
|
|
damage_bonus_neg,
|
|
damage_percent: f.player_mod_damage_done_pct(0).unwrap_or(1.0),
|
|
// `0` counts as unstreamed: a created store reads an absent in-block field as `Some(0)`,
|
|
// so a plain `unwrap_or` would leave `damage / speed` infinite.
|
|
main_attack_time_ms: base_swing(f.unit_base_attack_time(0)),
|
|
offhand_attack_time_ms: base_swing(f.unit_base_attack_time(1)),
|
|
attack_power: f.unit_attack_power().unwrap_or(0),
|
|
attack_power_pos: i32::from(ap_pos),
|
|
attack_power_neg: i32::from(ap_neg),
|
|
ranged_attack_power: f.unit_ranged_attack_power().unwrap_or(0),
|
|
ranged_attack_power_pos: i32::from(rap_pos),
|
|
ranged_attack_power_neg: i32::from(rap_neg),
|
|
ranged_attack_time_ms: base_swing(f.unit_ranged_attack_time()),
|
|
ranged_min_damage: f.unit_min_ranged_damage().unwrap_or(0.0),
|
|
ranged_max_damage: f.unit_max_ranged_damage().unwrap_or(0.0),
|
|
// The equipment and skill half is `combat_stats`'s; any other unit keeps these defaults.
|
|
has_offhand: false,
|
|
has_wand: false,
|
|
main_weapon_skill: (0, 0),
|
|
offhand_weapon_skill: (0, 0),
|
|
ranged_weapon_skill: (0, 0),
|
|
defense_skill: (0, 0),
|
|
dodge_percent: 0.0,
|
|
parry_percent: 0.0,
|
|
block_percent: 0.0,
|
|
}
|
|
}
|
|
|
|
/// The player's snapshot: [`unit_combat_stats`] plus the equipment flags and the skill pairs.
|
|
fn combat_stats(
|
|
store: &ObjectStore,
|
|
objects: &Objects,
|
|
items: &Items,
|
|
commands: &NetCommands,
|
|
) -> UnitCombatStats {
|
|
// The off hand counts only as a weapon (a shield does not swing); both flags read false
|
|
// until the template lands.
|
|
let has_offhand = slot_entry(store, objects, SLOT_OFF_HAND)
|
|
.and_then(|e| items.template(e, 0, commands))
|
|
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON);
|
|
let has_wand = slot_entry(store, objects, SLOT_RANGED)
|
|
.and_then(|e| items.template(e, 0, commands))
|
|
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON && t.subclass == SUBCLASS_WAND);
|
|
|
|
let main_skill = weapon_skill_id(store, objects, items, commands, SLOT_MAIN_HAND);
|
|
let offhand_skill = weapon_skill_id(store, objects, items, commands, SLOT_OFF_HAND);
|
|
let ranged_skill_id = slot_entry(store, objects, SLOT_RANGED)
|
|
.and_then(|e| items.template(e, 0, commands))
|
|
.filter(|t| t.class == ITEM_CLASS_WEAPON)
|
|
.and_then(|t| weapon_subclass_skill(t.subclass));
|
|
|
|
UnitCombatStats {
|
|
has_offhand,
|
|
has_wand,
|
|
main_weapon_skill: skill_pair(store, main_skill),
|
|
// `UnitAttackBothHands`'s second pair; a shield or an empty off hand reads Unarmed.
|
|
offhand_weapon_skill: skill_pair(store, offhand_skill),
|
|
ranged_weapon_skill: ranged_skill_id.map_or((0, 0), |id| skill_pair(store, id)),
|
|
// `UnitDefense`'s pair. The 1.12 player sheet has no Defense row: its `SKILL_LINES_CHANGED`
|
|
// arm (`PaperDollFrame.lua:70-72`) redraws only the Attack row.
|
|
defense_skill: skill_pair(store, SKILL_DEFENSE),
|
|
// `GetDodgeChance`/`GetParryChance`/`GetBlockChance`: player fields, already percentages.
|
|
dodge_percent: store.0.player_dodge_percentage().unwrap_or(0.0),
|
|
parry_percent: store.0.player_parry_percentage().unwrap_or(0.0),
|
|
block_percent: store.0.player_block_percentage().unwrap_or(0.0),
|
|
..unit_combat_stats(store)
|
|
}
|
|
}
|
|
|
|
/// One slot's view from the item object and its ask-once template (no icon while in flight).
|
|
/// Keyed by guid and live id: the doll (`PLAYER_FIELD_INV_SLOT_*`, live id = slot + 1) and the
|
|
/// bank bags (`PLAYER_FIELD_BANK_BAG_SLOT_*`, 64..=69) read their guids from different arrays.
|
|
fn slot_view(
|
|
objects: &Objects,
|
|
items: &Items,
|
|
icons: Option<&ItemDisplays>,
|
|
rolls: crate::items::RollCatalogs,
|
|
commands: &NetCommands,
|
|
pending: &PendingItemOps,
|
|
names: &crate::names::NameCache,
|
|
// `ItemSubClass.dbc`, for the count gate below; absent, every bag counts 0.
|
|
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
|
// The player's `ChrClasses.dbc` relic flag, which decides `find_equip_slot`'s INVSLOT 17.
|
|
has_relic_slot: bool,
|
|
// `DurabilityCosts.dbc`/`DurabilityQuality.dbc` and the open merchant's price discount, the
|
|
// two inputs to `0x4faf30`. Absent tables answer 0, as the merchant window's rows do.
|
|
repair: (Option<&crate::ui_merchant::RepairTables>, f64),
|
|
// The three inputs an equipped slot's use cooldown needs, grouped as `repair` is: the record
|
|
// list, the spell catalog its query keys on, and the frame's one clock pair. Absent, every
|
|
// slot reads cold — which is what this verb answered unconditionally before it was fed.
|
|
cooldowns: (
|
|
Option<&crate::spell::Cooldowns>,
|
|
Option<&crate::ui_action::Spells>,
|
|
std::time::Instant,
|
|
f64,
|
|
),
|
|
guid: u64,
|
|
live_id: u32,
|
|
) -> Option<InvSlotView> {
|
|
let countdowns = objects.countdowns(guid);
|
|
let enchant_ms: [Option<u64>; crate::items::ENCHANT_SLOTS] =
|
|
std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32)));
|
|
let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms());
|
|
let obj = objects.object(guid)?;
|
|
let entry = obj.object_entry()?;
|
|
let count = obj.item_stack_count().unwrap_or(1).max(1);
|
|
// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER`, read off the object as `GetInventoryItemCount`'s
|
|
// first fork reads it (`0x4c87a6`).
|
|
let is_container = obj.is_container();
|
|
let content_guids: Vec<u64> = {
|
|
let slots = obj.container_num_slots().unwrap_or(0).min(36) as u8;
|
|
(0..slots).filter_map(|s| obj.container_slot(s)).collect()
|
|
};
|
|
let durability = obj
|
|
.item_durability()
|
|
.zip(obj.item_max_durability())
|
|
.filter(|&(_, max)| max > 0);
|
|
// `0x4faf30` over this very item, through the merchant window's own one implementation
|
|
// ([`crate::ui_merchant::item_repair_cost`]) rather than a second copy of the arithmetic: a
|
|
// repair quote that disagreed with the vendor's bill would be a bug no caller could see.
|
|
// Computed here and not per Lua call because this whole builder is edge-gated — `feed_char`
|
|
// runs it only when an item object, a template, the discount or a pending op moved, and a
|
|
// slot's cost can only change when one of those does.
|
|
let repair_cost = repair.0.map_or(0, |tables| {
|
|
crate::ui_merchant::item_repair_cost(guid, objects, items, tables, commands, repair.1)
|
|
});
|
|
// `ITEM_FIELD_FLAGS`, for its wrapped (0x08) and force-red (0x10) bits.
|
|
let flags = obj.item_flags().unwrap_or(0);
|
|
// Soulbound or carrying a binding enchant (`0x5da2c0`), read off the raw descriptor, never off
|
|
// the enchant lines below.
|
|
let already_bound = crate::items::already_bound(obj, rolls.enchants);
|
|
let creator = obj
|
|
.item_creator()
|
|
.filter(|&g| g != 0)
|
|
.and_then(|g| names.resolve(g, commands).map(str::to_string));
|
|
// All seven enchant slots: our own gear streams as item objects, where an inspected player's
|
|
// descriptor carries only two.
|
|
let enchants = crate::items::enchant_lines(
|
|
(0..7).map(|s| {
|
|
(
|
|
s,
|
|
obj.item_enchant(s).unwrap_or(0),
|
|
obj.item_enchant_charges(s),
|
|
enchant_ms[usize::from(s)],
|
|
)
|
|
}),
|
|
rolls.enchants,
|
|
);
|
|
// `ITEM_FIELD_RANDOM_PROPERTIES_ID`, the roll behind an "of the Bear" name; its enchants are
|
|
// already in the slots above.
|
|
let roll = obj.item_random_properties_id();
|
|
let t = items.template(entry, guid, commands);
|
|
// The template's **on-use** spell block, the one a cooldown is queried against; the others
|
|
// are equip and proc triggers (`ui_items/feed.rs`'s bag cooldown reads the same field).
|
|
let use_spell = t.and_then(|t| t.use_spell);
|
|
let (name, quality, display, link, equip_slots, kind, bar_placeable) = match t {
|
|
Some(t) => {
|
|
let name = rolls.name(&t.name, roll);
|
|
(
|
|
Some(name.clone()),
|
|
t.quality as i32,
|
|
t.display_info_id,
|
|
Some(crate::ui_items::item_link_full(
|
|
entry, 0, roll, 0, &name, t.quality,
|
|
)),
|
|
find_equip_slot(t.inventory_type, has_relic_slot),
|
|
(t.class, t.subclass),
|
|
t.placeable_on_action_bar(),
|
|
)
|
|
}
|
|
None => (None, 0, 0, None, Vec::new(), (0, 0), false),
|
|
};
|
|
// A container counts its contents' stacks when its subclass's `DisplayFlags` has 0x4, else 0
|
|
// (`GetInventoryItemCount` `0x4c8680`: `0x4c881a`, summed by `0x622130`); the bit is on Soul
|
|
// Bag (1/1) and the Quiver class (11/0..3) only, so a plain bag shows no count.
|
|
let counts_contents = sub_classes.is_some_and(|c| c.0.display_flags(kind.0, kind.1) & 0x4 != 0);
|
|
let contents_count = is_container.then(|| {
|
|
if counts_contents {
|
|
content_guids
|
|
.iter()
|
|
.filter_map(|&g| objects.object(g))
|
|
.map(|o| o.item_stack_count().unwrap_or(1).max(1))
|
|
.sum()
|
|
} else {
|
|
0
|
|
}
|
|
});
|
|
let icon = icons
|
|
.and_then(|i| i.catalog.get(display))
|
|
.and_then(|d| d.icon.clone());
|
|
Some(InvSlotView {
|
|
item_id: entry,
|
|
icon,
|
|
count,
|
|
contents_count,
|
|
quality,
|
|
name,
|
|
link,
|
|
durability,
|
|
repair_cost,
|
|
flags,
|
|
already_bound,
|
|
locked: pending.contains(EQUIPMENT_BAG, live_id),
|
|
equip_slots,
|
|
bar_placeable,
|
|
creator,
|
|
enchants,
|
|
duration_ms,
|
|
// The raw descriptor numbers, beside the resolved lines above: nampower's equipment
|
|
// getters report the ids whether or not `SpellItemEnchantment` names them.
|
|
raw: benilla_ui::script::ItemRawFields {
|
|
enchant_ids: std::array::from_fn(|s| {
|
|
obj.item_enchant(u8::try_from(s).unwrap_or(0)).unwrap_or(0)
|
|
}),
|
|
temp_enchant_charges: obj.item_enchant_charges(1),
|
|
temp_enchant_remaining_ms: enchant_ms[1],
|
|
random_properties_id: obj.item_random_properties_id(),
|
|
spell_charges: obj.item_spell_charges(0).unwrap_or(0),
|
|
},
|
|
// The use cooldown, keyed on the (use spell, item entry) pair exactly as the bag feed's
|
|
// is — `Cooldowns::info` matches an item record on both, so a trinket's record is found
|
|
// whether it was armed from a bag slot or from the doll.
|
|
use_spell_id: use_spell.map_or(0, |u| u.spell_id),
|
|
cooldown: use_spell.and_then(|u| {
|
|
let (Some(cds), anchor, ui_now) = (cooldowns.0, cooldowns.2, cooldowns.3) else {
|
|
return None;
|
|
};
|
|
let sd = cooldowns.1.and_then(|s| s.catalog.get(u.spell_id));
|
|
cds.info(u.spell_id, entry, sd, anchor)
|
|
.ui_triple(anchor, ui_now)
|
|
}),
|
|
cooldown_legs: match (use_spell, cooldowns.0) {
|
|
(Some(u), Some(cds)) => {
|
|
let sd = cooldowns.1.and_then(|s| s.catalog.get(u.spell_id));
|
|
crate::ui_script::cooldown_legs_view(
|
|
&cds.legs(u.spell_id, entry, sd, cooldowns.2),
|
|
cooldowns.2,
|
|
cooldowns.3,
|
|
)
|
|
}
|
|
_ => Default::default(),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Carried ammo matching `ammo_id`, in the backpack and the four equipped bags; bank and buyback
|
|
/// never count.
|
|
fn ammo_count(store: &ObjectStore, objects: &Objects, ammo_id: u32) -> u32 {
|
|
let mut n = 0;
|
|
let mut add = |guid: Option<u64>| {
|
|
if let Some(o) = guid.and_then(|g| objects.object(g)) {
|
|
if o.object_entry() == Some(ammo_id) {
|
|
n += o.item_stack_count().unwrap_or(1);
|
|
}
|
|
}
|
|
};
|
|
for i in 0..16u8 {
|
|
add(store.0.player_pack_slot(i));
|
|
}
|
|
for b in 19..23u8 {
|
|
if let Some(bag) = store.0.player_inv_slot(b).and_then(|g| objects.object(g)) {
|
|
let slots = bag.container_num_slots().unwrap_or(0).min(36) as u8;
|
|
for s in 0..slots {
|
|
if let Some(o) = bag.container_slot(s).and_then(|g| objects.object(g)) {
|
|
if o.object_entry() == Some(ammo_id) {
|
|
n += o.item_stack_count().unwrap_or(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
/// The 24-wide inventory snapshot by `GetInventorySlotInfo` id: 0 ammo, 1..=19 the equipment,
|
|
/// 20..=23 the equipped bags themselves (inv slots 19..22).
|
|
fn inventory_slots(
|
|
store: &ObjectStore,
|
|
objects: &Objects,
|
|
items: &Items,
|
|
icons: Option<&ItemDisplays>,
|
|
rolls: crate::items::RollCatalogs,
|
|
commands: &NetCommands,
|
|
pending: &PendingItemOps,
|
|
names: &crate::names::NameCache,
|
|
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
|
classes: Option<&benilla_formats::ChrClasses>,
|
|
repair: (Option<&crate::ui_merchant::RepairTables>, f64),
|
|
cooldowns: (
|
|
Option<&crate::spell::Cooldowns>,
|
|
Option<&crate::ui_action::Spells>,
|
|
std::time::Instant,
|
|
f64,
|
|
),
|
|
) -> InventorySlots {
|
|
let has_relic_slot = store
|
|
.0
|
|
.unit_class()
|
|
.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c))));
|
|
let mut inv: InventorySlots = Default::default();
|
|
let ammo_id = store.0.player_ammo_id().unwrap_or(0);
|
|
if ammo_id != 0 {
|
|
let t = items.template(ammo_id, 0, commands);
|
|
let (name, quality, display, link) = match t {
|
|
Some(t) => (
|
|
Some(t.name.clone()),
|
|
t.quality as i32,
|
|
t.display_info_id,
|
|
Some(item_link(ammo_id, &t.name, t.quality)),
|
|
),
|
|
None => (None, 0, 0, None),
|
|
};
|
|
let icon = icons
|
|
.and_then(|i| i.catalog.get(display))
|
|
.and_then(|d| d.icon.clone());
|
|
inv[0] = Some(InvSlotView {
|
|
durability: None,
|
|
// Ammo has no durability and `0x4faf30` charges nothing for a slot it cannot read.
|
|
repair_cost: 0,
|
|
flags: 0,
|
|
// Ammo has no item object, only its template, so there is no bind to read.
|
|
already_bound: false,
|
|
item_id: ammo_id,
|
|
icon,
|
|
count: ammo_count(store, objects, ammo_id),
|
|
// `GetInventoryItemCount`'s `PLAYER_AMMO_ID` leg, before the container fork.
|
|
contents_count: None,
|
|
quality,
|
|
name,
|
|
link,
|
|
// No click or drag path reaches the ammo slot: nothing locks it, it fits nowhere.
|
|
locked: false,
|
|
equip_slots: Vec::new(),
|
|
bar_placeable: false,
|
|
creator: None,
|
|
// No ammo instance streams, only its template id: no enchants, no countdown, and
|
|
// none of the raw descriptor numbers either.
|
|
enchants: Vec::new(),
|
|
duration_ms: None,
|
|
raw: benilla_ui::script::ItemRawFields::default(),
|
|
// Ammo has no on-use spell and so no cooldown of its own.
|
|
cooldown: None,
|
|
cooldown_legs: Default::default(),
|
|
use_spell_id: 0,
|
|
});
|
|
}
|
|
// Equipment 1..=19, then `Bag0Slot`..`Bag3Slot` (20..=23 in `PaperDollItemFrame.dbc`), the
|
|
// equipped bag items: the live id is the inv slot plus one across the band.
|
|
for live in 1..=23u32 {
|
|
let Some(guid) = store.0.player_inv_slot(live as u8 - 1).filter(|g| *g != 0) else {
|
|
continue;
|
|
};
|
|
inv[live as usize] = slot_view(
|
|
objects,
|
|
items,
|
|
icons,
|
|
rolls,
|
|
commands,
|
|
pending,
|
|
names,
|
|
sub_classes,
|
|
has_relic_slot,
|
|
repair,
|
|
cooldowns,
|
|
guid,
|
|
live,
|
|
);
|
|
}
|
|
inv
|
|
}
|
|
|
|
/// The six bank-bag slots' views (live ids 64..=69). They are inventory slots, streamed whether
|
|
/// or not a banker is open, and the stock bank reads them through `BankButtonIDToInvSlotID(i, 1)`
|
|
/// (`BankFrame.lua:28-35, 199-204`), so they ride this feed rather than the bank window's state.
|
|
fn bank_bag_slots(
|
|
store: &ObjectStore,
|
|
objects: &Objects,
|
|
items: &Items,
|
|
icons: Option<&ItemDisplays>,
|
|
rolls: crate::items::RollCatalogs,
|
|
commands: &NetCommands,
|
|
pending: &PendingItemOps,
|
|
names: &crate::names::NameCache,
|
|
sub_classes: Option<&crate::ui_items::ItemSubClasses>,
|
|
repair: (Option<&crate::ui_merchant::RepairTables>, f64),
|
|
cooldowns: (
|
|
Option<&crate::spell::Cooldowns>,
|
|
Option<&crate::ui_action::Spells>,
|
|
std::time::Instant,
|
|
f64,
|
|
),
|
|
) -> BankBagSlots {
|
|
let mut bags: BankBagSlots = Default::default();
|
|
for (i, bag) in bags.iter_mut().enumerate() {
|
|
let Some(guid) = store.0.player_bank_bag_slot(i as u8).filter(|g| *g != 0) else {
|
|
continue;
|
|
};
|
|
*bag = slot_view(
|
|
objects,
|
|
items,
|
|
icons,
|
|
rolls,
|
|
commands,
|
|
pending,
|
|
names,
|
|
sub_classes,
|
|
// A bank bag slot holds a bag; the relic flag decides INVSLOT 17 alone.
|
|
false,
|
|
// A bag is item class 1, which `DurabilityCosts` has no column for, so every bank bag
|
|
// answers 0 through the same arithmetic rather than by a special case here.
|
|
repair,
|
|
// A bank bag has no use spell, so this is the cold path either way; passed rather
|
|
// than stubbed so there is one shape of call into `slot_view`.
|
|
cooldowns,
|
|
guid,
|
|
BANK_BAG_LIVE_FIRST + i as u32,
|
|
);
|
|
}
|
|
bags
|
|
}
|
|
|
|
/// `BankButtonIDToInvSlotID(1, isBag)`: the first of the six bank-bag slots' live ids.
|
|
const BANK_BAG_LIVE_FIRST: u32 = 64;
|
|
|
|
/// Fire the paper doll's repaint events for one unit's snapshot transition, `arg1 = token`: the
|
|
/// eight unit-stat events both pages register (`PaperDollFrame.lua:14-28`,
|
|
/// `PetPaperDollFrame.lua:12-20`), each when a value its handlers read moved. A first snapshot
|
|
/// fires them all.
|
|
pub(crate) fn fire_stat_transitions(
|
|
script: &mut UiScript,
|
|
token: &str,
|
|
prev: Option<&UnitCombatStats>,
|
|
stats: &UnitCombatStats,
|
|
) {
|
|
let tok = || ScriptValue::Str(token.to_string());
|
|
let changed = |sel: fn(&UnitCombatStats) -> Vec<i64>| prev.is_none_or(|p| sel(p) != sel(stats));
|
|
if changed(|s| {
|
|
let mut v: Vec<i64> = s.stats.iter().map(|&x| i64::from(x)).collect();
|
|
v.extend(
|
|
s.stat_pos
|
|
.iter()
|
|
.chain(s.stat_neg.iter())
|
|
.map(|&x| i64::from(x)),
|
|
);
|
|
v
|
|
}) {
|
|
script.fire_event("UNIT_STATS", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
s.resistances
|
|
.iter()
|
|
.chain(s.resistance_pos.iter())
|
|
.chain(s.resistance_neg.iter())
|
|
.map(|&x| i64::from(x))
|
|
.collect()
|
|
}) {
|
|
script.fire_event("UNIT_RESISTANCES", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
fixed(s.min_damage),
|
|
fixed(s.max_damage),
|
|
fixed(s.min_offhand_damage),
|
|
fixed(s.max_offhand_damage),
|
|
i64::from(s.damage_bonus_pos[0]),
|
|
i64::from(s.damage_bonus_neg[0]),
|
|
fixed(s.damage_percent),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_DAMAGE", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
i64::from(s.main_attack_time_ms),
|
|
i64::from(s.offhand_attack_time_ms),
|
|
i64::from(s.has_offhand),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_ATTACK_SPEED", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
i64::from(s.attack_power),
|
|
i64::from(s.attack_power_pos),
|
|
i64::from(s.attack_power_neg),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_ATTACK_POWER", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
i64::from(s.ranged_attack_power),
|
|
i64::from(s.ranged_attack_power_pos),
|
|
i64::from(s.ranged_attack_power_neg),
|
|
i64::from(s.has_wand),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_RANGED_ATTACK_POWER", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
fixed(s.ranged_min_damage),
|
|
fixed(s.ranged_max_damage),
|
|
i64::from(s.ranged_attack_time_ms),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_RANGEDDAMAGE", vec![tok()]);
|
|
}
|
|
if changed(|s| {
|
|
vec![
|
|
i64::from(s.main_weapon_skill.0),
|
|
i64::from(s.main_weapon_skill.1),
|
|
i64::from(s.ranged_weapon_skill.0),
|
|
i64::from(s.ranged_weapon_skill.1),
|
|
]
|
|
}) {
|
|
script.fire_event("UNIT_ATTACK", vec![tok()]);
|
|
}
|
|
}
|
|
|
|
/// Push the player's own combat sheet into a freshly built VM, **before its addon walk**.
|
|
///
|
|
/// `load_ingame_ui_on_world_entry` seeds the player's roster record and unit snapshot for exactly
|
|
/// this reason — an addon reads them at file scope — and the sheet belongs in the same seat,
|
|
/// because the one number everything melee divides by is latched there. SP_SwingTimer caches
|
|
/// `UnitAttackSpeed("player")` into `st_timerMax` in its `ADDON_LOADED` arm behind an
|
|
/// `if not st_timerMax`, and CleveRoids computes its `[noslamclip]` window from that cache rather
|
|
/// than from the verb, so a stand-in read at load is the window for the whole first fight:
|
|
/// (2.0 - 1.5)/2.0 = 25% where the player's 2.70 s weapon gives 44%, with the swing reading 100%
|
|
/// elapsed until the first auto-attack lands and `ResetTimer` replaces it.
|
|
///
|
|
/// A fresh login has no self store yet — the create block has not arrived, which is why the record
|
|
/// comes off the roster — and there is nothing to seed; `feed_char` pushes a frame or two later
|
|
/// and the latch heals on the first swing. A `/reload`, which is the common case for an addon
|
|
/// author and for anyone mid-fight, has the store and gets the real weapon.
|
|
pub(crate) fn seed_combat_stats(world: &mut World, script: &mut UiScript) {
|
|
// Every resource optional and read before the `SystemState` is built: a world-entry test
|
|
// world holds neither `Items` nor `NetCommands`, and a `SystemState` over a missing `Res`
|
|
// panics on `get` rather than answering `None`.
|
|
if !world.contains_resource::<Items>() || !world.contains_resource::<NetCommands>() {
|
|
return;
|
|
}
|
|
let mut state = bevy::ecs::system::SystemState::<(
|
|
crate::items::Inventory,
|
|
Res<Items>,
|
|
Res<NetCommands>,
|
|
)>::new(world);
|
|
let (inv, items, commands) = state.get(world);
|
|
let Some(store) = inv.self_store.iter().next() else {
|
|
return;
|
|
};
|
|
script.set_player_combat_stats(Some(combat_stats(store, &inv.objects, &items, &commands)));
|
|
}
|
|
|
|
pub(crate) fn feed_char(
|
|
// `ChrClasses.dbc` field 16, the relic flag `find_equip_slot` reads; absent, no class has one.
|
|
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut inv: crate::items::Inventory,
|
|
items: Res<Items>,
|
|
icons: Option<Res<ItemDisplays>>,
|
|
enchants: Option<Res<crate::items::Enchants>>,
|
|
props: Option<Res<crate::items::RandomProperties>>,
|
|
commands: Res<NetCommands>,
|
|
mut feed: ResMut<CharFeedState>,
|
|
mut booth: ResMut<PaperDollBooth>,
|
|
pending: Res<PendingItemOps>,
|
|
names: Res<crate::names::NameCache>,
|
|
// `ItemSubClass.dbc`, for `GetInventoryItemCount`'s container count bit.
|
|
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
|
|
repair_in: RepairInputs,
|
|
// An equipped slot's use cooldown. `GetInventoryItemCooldown` is a **stock** verb and read
|
|
// `(0, 0, 1)` unconditionally before this was taken, so no trinket ever drew its swirl on the
|
|
// paper doll.
|
|
cooldowns: ItemCooldownInputs,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
// The stock `CharacterModelFrame` owns the yaw (`Model_Rotate*` call `SetRotation`); the booth
|
|
// mirrors it, written only on change so it is not marked changed every frame.
|
|
let yaw = script.model_pane_facing("CharacterModelFrame");
|
|
if booth.yaw != yaw {
|
|
booth.yaw = yaw;
|
|
}
|
|
|
|
// Per VM: after a `/reload` both snapshots re-push and their events re-fire.
|
|
let (memo, vm_reset) = feed.vm.get_reset(&script);
|
|
|
|
let Some(store) = inv.self_store.iter().next() else {
|
|
// No self store is no data, not an empty inventory: it happens in the logout frames, where
|
|
// `PLAYER_LOGOUT` handlers still read equipment, so nothing is cleared (the next session
|
|
// starts a fresh VM).
|
|
return;
|
|
};
|
|
|
|
// `GetWeaponEnchantInfo`'s data, pushed every frame outside the gate: the remaining time is a
|
|
// live countdown, which the reference also recomputes per call (`0x5d9d00`). Pushed first, so
|
|
// handlers the snapshots fire read the fresh value.
|
|
let objects = &inv.objects;
|
|
script.set_weapon_enchants(
|
|
weapon_enchant(store, objects, EQUIPMENT_SLOT_MAINHAND),
|
|
weapon_enchant(store, objects, EQUIPMENT_SLOT_OFFHAND),
|
|
);
|
|
|
|
// `0x4faf30`'s price discount, recomputed per frame and *outside* the gate so it can be a gate
|
|
// input. It is free with no vendor window up — `MerchantOpen::vendor` is None and the guid
|
|
// lookup never runs — and with one up it is a hashmap get plus `UnitReaction`'s own two DBC
|
|
// reads, which the nameplate ring already pays every frame for every unit on screen.
|
|
//
|
|
// 0.0 with no merchant open is not a fallback, it is the reference's answer: `0x4faf30` takes
|
|
// the discount only once it resolves the merchant guid the vendor list set (ClassicAPI's own
|
|
// `src/item/RepairCost.cpp` says the same of the function this feeds — "outside a vendor
|
|
// interaction the engine short-circuits the discount path and we get the raw base cost"). So
|
|
// a panel tooltip read with no vendor in sight quotes the full list price, and the same read
|
|
// at an Honored repair vendor quotes the 10% the vendor will actually charge — the merchant
|
|
// window's own repair rows and `GetRepairAllCost` move with it, because all three are this
|
|
// one number. The price is never under-quoted: the discount only ever takes money off.
|
|
//
|
|
// No `UNIT_NPC_FLAG_REPAIR` test, matching `RepairPurse::refuses`: the discount is a property
|
|
// of the open merchant, not of its ability to repair.
|
|
let discount = repair_in
|
|
.merchant
|
|
.vendor
|
|
.and_then(|g| objects.store(g))
|
|
.map_or(0.0, |vendor| {
|
|
crate::target::vendor_price_discount(
|
|
repair_in.reactions.factions.as_deref(),
|
|
&repair_in.reactions.reputations,
|
|
vendor,
|
|
store,
|
|
)
|
|
});
|
|
|
|
// The gate, around the two snapshot builds only.
|
|
let objects_moved = inv.changes.moved();
|
|
let templates_moved = memo.items_templates.moved(items.template_epoch());
|
|
let names_moved = memo.names_generation.moved(names.generation());
|
|
// The display epoch, not a per-frame hold-open: the views read whole-second countdowns, so
|
|
// they can change only when it steps.
|
|
let deadlines_moved = memo.enchant_deadlines.moved(inv.changes.countdown_steps());
|
|
let self_changed = !inv.self_changed.is_empty();
|
|
// `is_added`, not `is_changed`: only the load-once icon column is read, and the model-cache
|
|
// half churns every frame.
|
|
let icons_added = icons.as_ref().is_some_and(|r| r.is_added());
|
|
// Both catalogs load once at startup; one input covers the pair.
|
|
let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed())
|
|
|| props.as_ref().is_some_and(|r| r.is_changed());
|
|
let cooldowns_moved = memo.cooldowns_epoch.moved(cooldowns.epoch());
|
|
let pending_held = !pending.is_empty();
|
|
let pending_moved = memo.pending_epoch.moved(pending.epoch());
|
|
// The f64 bits, not the value: a discount is one of five exact constants (0, .05, .10, .15,
|
|
// .20), so bit equality is the right comparison and never spuriously opens the gate.
|
|
let discount_moved = memo.repair_discount.moved(discount.to_bits());
|
|
// The tables load once at startup; a frame before they land every cost reads 0, and this is
|
|
// what re-runs the build on the frame they arrive.
|
|
let tables_added = repair_in.tables.as_ref().is_some_and(|r| r.is_added());
|
|
gate::trace(
|
|
"feed_char",
|
|
&[
|
|
("vm_reset", vm_reset),
|
|
("objects", objects_moved),
|
|
("templates", templates_moved),
|
|
("names", names_moved),
|
|
("deadlines", deadlines_moved),
|
|
("self", self_changed),
|
|
("icons", icons_added),
|
|
("enchants", enchants_changed),
|
|
("pending", pending_held),
|
|
("pending-epoch", pending_moved),
|
|
("repair-discount", discount_moved),
|
|
("repair-tables", tables_added),
|
|
("cooldowns", cooldowns_moved),
|
|
],
|
|
);
|
|
let gate = gate::Gate::new(
|
|
vm_reset
|
|
|| objects_moved
|
|
|| templates_moved
|
|
|| names_moved
|
|
|| deadlines_moved
|
|
|| self_changed
|
|
|| icons_added
|
|
|| enchants_changed
|
|
|| pending_held
|
|
|| pending_moved
|
|
|| discount_moved
|
|
|| tables_added
|
|
|| cooldowns_moved,
|
|
);
|
|
if gate.skip() {
|
|
return;
|
|
}
|
|
|
|
let stats = combat_stats(store, objects, &items, &commands);
|
|
if memo.last_stats.as_ref() != Some(&stats) {
|
|
gate.audit("feed_char", "the combat-stats snapshot");
|
|
// Push before firing: handlers run synchronously and would paint the old values.
|
|
let prev = memo.last_stats.take();
|
|
script.set_player_combat_stats(Some(stats.clone()));
|
|
fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats);
|
|
memo.last_stats = Some(stats);
|
|
}
|
|
|
|
let inv = inventory_slots(
|
|
store,
|
|
objects,
|
|
&items,
|
|
icons.as_deref(),
|
|
crate::items::RollCatalogs {
|
|
enchants: enchants.as_deref(),
|
|
props: props.as_deref(),
|
|
},
|
|
&commands,
|
|
&pending,
|
|
&names,
|
|
sub_classes.as_deref(),
|
|
classes.as_deref().map(|t| &t.0),
|
|
(repair_in.tables.as_deref(), discount),
|
|
cooldowns.parts(),
|
|
);
|
|
let bank_bags = bank_bag_slots(
|
|
store,
|
|
objects,
|
|
&items,
|
|
icons.as_deref(),
|
|
crate::items::RollCatalogs {
|
|
enchants: enchants.as_deref(),
|
|
props: props.as_deref(),
|
|
},
|
|
&commands,
|
|
&pending,
|
|
&names,
|
|
sub_classes.as_deref(),
|
|
(repair_in.tables.as_deref(), discount),
|
|
cooldowns.parts(),
|
|
);
|
|
// Both bands push under one `UNIT_INVENTORY_CHANGED`: one wire update, one event.
|
|
// `PLAYERBANKSLOTS_CHANGED`'s two producers, planned off the old memo: `false` is the
|
|
// descriptor watcher (`0x5ddd6e`: a bag arrived, left or was swapped), `true` the item-object
|
|
// path (`0x4c728d`: the same bag's own fields changed).
|
|
let bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT] =
|
|
std::array::from_fn(|i| store.0.player_bank_bag_slot(i as u8).unwrap_or(0));
|
|
let repainted: Vec<bool> = (0..BANK_BAG_SLOT_COUNT)
|
|
.filter_map(|i| {
|
|
if bank_bag_guids[i] != memo.last_bank_bag_guids[i] {
|
|
Some(false)
|
|
} else {
|
|
let was = memo.last_bank_bags.as_ref().and_then(|b| b[i].as_ref());
|
|
(!same_item(was, bank_bags[i].as_ref())).then_some(true)
|
|
}
|
|
})
|
|
.collect();
|
|
memo.last_bank_bag_guids = bank_bag_guids;
|
|
if memo.last_inv.as_ref() != Some(&inv) || memo.last_bank_bags.as_ref() != Some(&bank_bags) {
|
|
gate.audit("feed_char", "the inventory snapshot");
|
|
script.set_inventory_slots(inv.clone());
|
|
script.set_bank_bag_slots(bank_bags.clone());
|
|
script.fire_event(
|
|
"UNIT_INVENTORY_CHANGED",
|
|
vec![ScriptValue::Str("player".to_string())],
|
|
);
|
|
// The bank bag buttons repaint only on this and `BANKFRAME_OPENED`
|
|
// (`BankFrame.lua:206-208`). One event per changed slot: the descriptor watcher signals
|
|
// through `0x703e50` with no arguments, the item path through `SignalEvent2` with
|
|
// `"player"`.
|
|
memo.last_inv = Some(inv);
|
|
memo.last_bank_bags = Some(bank_bags);
|
|
}
|
|
// Outside the push block: an identical-bag swap moves no view at all.
|
|
if !repainted.is_empty() {
|
|
gate.audit("feed_char", "a bank bag slot transition");
|
|
}
|
|
for same_bag in repainted {
|
|
let args = if same_bag {
|
|
vec![ScriptValue::Str("player".to_string())]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
script.fire_event("PLAYERBANKSLOTS_CHANGED", args);
|
|
}
|
|
}
|
|
|
|
/// Whether two bank-bag views hold the same item; `locked` is `ITEM_LOCK_CHANGED`'s business.
|
|
fn same_item(a: Option<&InvSlotView>, b: Option<&InvSlotView>) -> bool {
|
|
match (a, b) {
|
|
(None, None) => true,
|
|
(Some(a), Some(b)) => {
|
|
let unlocked = |v: &InvSlotView| InvSlotView {
|
|
locked: false,
|
|
..v.clone()
|
|
};
|
|
unlocked(a) == unlocked(b)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// The 0-based weapon slots `GetWeaponEnchantInfo` (`0x4c9790`) reads (`0x4c97c3`, `0x4c988b`);
|
|
/// the frames' live ids are one higher.
|
|
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
|
|
const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
|
|
|
|
/// The temporary enchantment slot (0 permanent, 2..6 random properties), as the reference's
|
|
/// remaining-time read passes it (`0x4c9828`).
|
|
const TEMP_ENCHANTMENT_SLOT: u8 = 1;
|
|
|
|
/// One weapon's temporary enchantment, read off the raw descriptor rather than
|
|
/// [`InvSlotView::enchants`], which drops unnamed ids and the totem imbues; the reference's only
|
|
/// gate is a nonzero id (`[descriptor+0x4c]`).
|
|
fn weapon_enchant(
|
|
store: &ObjectStore,
|
|
objects: &Objects,
|
|
slot0: u8,
|
|
) -> Option<benilla_ui::script::WeaponEnchant> {
|
|
let guid = store.0.player_inv_slot(slot0)?;
|
|
// The deadline read, not the tooltip's: an elapsed timer answers 0, not "no timer".
|
|
let remaining_ms = objects
|
|
.countdowns(guid)
|
|
.and_then(|c| c.enchant_deadline_ms(u32::from(TEMP_ENCHANTMENT_SLOT)));
|
|
let obj = objects.object(guid)?;
|
|
// The id is the reference's own gate (`0x4c97f8`, a nonzero dword with no DBC lookup) and is
|
|
// now kept rather than discarded: SuperWoW's `GetWeaponEnchantID` is exactly this number.
|
|
let enchant_id = obj.item_enchant(TEMP_ENCHANTMENT_SLOT)?;
|
|
Some(benilla_ui::script::WeaponEnchant {
|
|
remaining_ms,
|
|
charges: obj.item_enchant_charges(TEMP_ENCHANTMENT_SLOT),
|
|
enchant_id,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use benilla_protocol::messages::ObjectFields;
|
|
use bevy::ecs::system::RunSystemOnce;
|
|
|
|
/// `GetSpellBonusDamage` reads schools 1..6 of the same field `UnitDamage` reports index 0
|
|
/// of, so the snapshot has to carry all seven. It used to read index 0 alone and drop the
|
|
/// rest, which is invisible to any test holding a hand-built `UnitCombatStats`.
|
|
#[test]
|
|
fn the_snapshot_carries_every_schools_damage_bonus_not_just_physical() {
|
|
/// `PLAYER_FIELD_MOD_DAMAGE_DONE_POS`, seven schools; `_NEG` is seven further on.
|
|
const F_DMG_POS: u16 = 1201;
|
|
const F_DMG_NEG: u16 = 1208;
|
|
// Physical +25/-3, fire (school 2) +60, shadow (school 5) +94/-14.
|
|
let store = ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_DMG_POS, 25),
|
|
(F_DMG_POS + 2, 60),
|
|
(F_DMG_POS + 5, 94),
|
|
(F_DMG_NEG, (-3i32) as u32),
|
|
(F_DMG_NEG + 5, (-14i32) as u32),
|
|
]));
|
|
let s = unit_combat_stats(&store);
|
|
assert_eq!(s.damage_bonus_pos, [25, 0, 60, 0, 0, 94, 0]);
|
|
assert_eq!(s.damage_bonus_neg, [-3, 0, 0, 0, 0, -14, 0]);
|
|
}
|
|
|
|
/// The reference's split (`0x5ea460`); a value-0 line skips the permanent add (`0x5ea4b4`).
|
|
#[test]
|
|
fn a_skill_pair_folds_the_permanent_bonus_into_the_base() {
|
|
/// `PLAYER_SKILL_INFO_1_1`: id|step, value|max, temp|perm per slot.
|
|
const F_SKILL: u16 = 718;
|
|
let pair = |lo: u16, hi: u16| u32::from(lo) | (u32::from(hi) << 16);
|
|
// Slot 0: Defense (95) at 300, +10 temporary, +5 permanent.
|
|
// Slot 1: Swords (43) at 0, with a +7 permanent that must not surface.
|
|
let store = ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_SKILL, pair(95, 0)),
|
|
(F_SKILL + 1, pair(300, 300)),
|
|
(F_SKILL + 2, pair(10, 5)),
|
|
(F_SKILL + 3, pair(43, 0)),
|
|
(F_SKILL + 4, pair(0, 300)),
|
|
(F_SKILL + 5, pair(0, 7)),
|
|
]));
|
|
assert_eq!(
|
|
skill_pair(&store, 95),
|
|
(305, 10),
|
|
"the permanent bonus belongs to the base and the temporary one is the modifier"
|
|
);
|
|
assert_eq!(
|
|
skill_pair(&store, 43),
|
|
(0, 0),
|
|
"a line at value 0 skips the perm add — a bare talent bonus is not a skill"
|
|
);
|
|
assert_eq!(
|
|
skill_pair(&store, 162),
|
|
(0, 0),
|
|
"a line the player does not have at all"
|
|
);
|
|
// A malus deeper than the skill is representable, which is why the pair is signed.
|
|
let cursed = ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_SKILL, pair(95, 0)),
|
|
(F_SKILL + 1, pair(5, 300)),
|
|
(F_SKILL + 2, pair(0, u16::MAX - 9)), // perm = -10
|
|
]));
|
|
assert_eq!(skill_pair(&cursed, 95), (-5, 0));
|
|
}
|
|
|
|
/// `PLAYER_FIELD_BANK_BAG_SLOT_1`, bank bag slot 0's guid pair.
|
|
const F_BANK_BAG_1: u16 = 612;
|
|
/// `OBJECT_FIELD_ENTRY` on the item object.
|
|
const F_OBJECT_ENTRY: u16 = 3;
|
|
/// `ITEM_FIELD_STACK_COUNT`, one of the six item fields the reference's watcher covers.
|
|
const F_ITEM_STACK_COUNT: u16 = 14;
|
|
/// Any container entry ("Traveler's Backpack"); the feed only needs it to resolve.
|
|
const BAG_ENTRY: u32 = 4500;
|
|
const BAG: u64 = 0x4000_0000_0000_0abc;
|
|
|
|
/// Re-stream a live item object's fields as a values update does, moving its change tick.
|
|
fn restream(app: &mut App, guid: u64, fields: ObjectFields) {
|
|
let entity = app
|
|
.world()
|
|
.resource::<crate::net::GuidIndex>()
|
|
.0
|
|
.get(&guid)
|
|
.copied()
|
|
.expect("the item was streamed");
|
|
*app.world_mut().get_mut::<ObjectStore>(entity).unwrap() = ObjectStore(fields);
|
|
}
|
|
|
|
/// A world with `bag`, or nothing, in the first bank bag slot, and a frame recording events.
|
|
fn world_with_bank_bag(bag: Option<u64>) -> App {
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
app.init_resource::<CharFeedState>()
|
|
.init_resource::<PaperDollBooth>()
|
|
.init_resource::<PendingItemOps>()
|
|
.init_resource::<Items>()
|
|
.init_resource::<crate::net::GuidIndex>()
|
|
.init_resource::<crate::names::NameCache>()
|
|
// The repair-cost inputs: with no vendor open and no tables loaded every slot's cost
|
|
// is 0, which is what these bank-bag tests want.
|
|
.init_resource::<crate::ui_merchant::MerchantOpen>()
|
|
.init_resource::<crate::ui_script::UiClock>()
|
|
.init_resource::<crate::net::Reputations>()
|
|
.insert_resource(NetCommands(tx));
|
|
let fields = match bag {
|
|
Some(g) => ObjectFields::from_pairs(&[
|
|
(F_BANK_BAG_1, g as u32),
|
|
(F_BANK_BAG_1 + 1, (g >> 32) as u32),
|
|
]),
|
|
None => ObjectFields::from_pairs(&[(F_BANK_BAG_1, 0), (F_BANK_BAG_1 + 1, 0)]),
|
|
};
|
|
app.world_mut().spawn((SelfPlayer, ObjectStore(fields)));
|
|
if let Some(g) = bag {
|
|
// The bag is an item entity; its template lives on `Items`.
|
|
crate::items::test_spawn_item(
|
|
app.world_mut(),
|
|
g,
|
|
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
|
|
true,
|
|
);
|
|
app.world_mut().resource_mut::<Items>().insert_template(
|
|
BAG_ENTRY,
|
|
Some(crate::items::test_template("Traveler's Backpack")),
|
|
);
|
|
}
|
|
let script = UiScript::new().unwrap();
|
|
script
|
|
.run(
|
|
r#"
|
|
SEEN = {}
|
|
local f = CreateFrame("Frame")
|
|
f:RegisterEvent("PLAYERBANKSLOTS_CHANGED")
|
|
f:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
|
f:SetScript("OnEvent", function()
|
|
table.insert(SEEN, event .. " " .. tostring(arg1))
|
|
end)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
app.insert_non_send_resource(script);
|
|
app
|
|
}
|
|
|
|
fn seen(app: &mut App) -> Vec<String> {
|
|
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
|
let out = script.eval::<Vec<String>>("return SEEN").unwrap();
|
|
script.run("SEEN = {}").unwrap();
|
|
out
|
|
}
|
|
|
|
/// `PLAYER_FIELD_INV_SLOT_HEAD` plus the main hand's two words (`EQUIPMENT_SLOT_MAINHAND` is
|
|
/// 0-based 15, live id 16).
|
|
const F_MAIN_HAND: u16 = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15;
|
|
/// `UNIT_FIELD_BASEATTACKTIME` (`OBJECT_END+0x78`), the main hand's slot.
|
|
const F_BASE_ATTACK_TIME: u16 = benilla_protocol::field::FIELD_UNIT_BASEATTACKTIME;
|
|
/// `ITEM_FIELD_DURABILITY` / `ITEM_FIELD_MAXDURABILITY`, absolute descriptor indices.
|
|
const F_ITEM_DURABILITY: u16 = 46;
|
|
const F_ITEM_MAXDURABILITY: u16 = 47;
|
|
/// `PLAYER_BYTES_3`, whose top byte is the *current* honor rank the discount reads.
|
|
const F_PLAYER_BYTES_3: u16 = 195;
|
|
const SWORD: u64 = 0x4000_0000_0000_0707;
|
|
const SWORD_ENTRY: u32 = 2488;
|
|
const VENDOR: u64 = 0xF130_0000_0000_0043;
|
|
|
|
/// One level-20 row at 1 copper a point for a sword (weapon subclass 7), quality 1's
|
|
/// multiplier (row `2·1 + 1`) at 1 — [`crate::ui_merchant`]'s own purse fixture tables, so the
|
|
/// arithmetic under test here is the merchant window's.
|
|
fn repair_tables() -> benilla_formats::DurabilityTables {
|
|
let mut row = vec![0u32; 29];
|
|
row[7] = 1;
|
|
benilla_formats::DurabilityTables::from_rows([(20, row)], [(3, 1.0)])
|
|
}
|
|
|
|
/// A world wearing one level-20 sword 40 durability points down in the main hand, the repair
|
|
/// tables loaded, and the player at honor rank 8. No merchant open.
|
|
fn world_wearing_a_damaged_sword() -> App {
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
app.init_resource::<CharFeedState>()
|
|
.init_resource::<PaperDollBooth>()
|
|
.init_resource::<PendingItemOps>()
|
|
.init_resource::<Items>()
|
|
.init_resource::<crate::net::GuidIndex>()
|
|
.init_resource::<crate::names::NameCache>()
|
|
.init_resource::<crate::ui_merchant::MerchantOpen>()
|
|
.init_resource::<crate::ui_script::UiClock>()
|
|
.init_resource::<crate::net::Reputations>()
|
|
.insert_resource(crate::ui_merchant::RepairTables(repair_tables()))
|
|
.insert_resource(NetCommands(tx));
|
|
app.world_mut().spawn((
|
|
SelfPlayer,
|
|
ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_MAIN_HAND, SWORD as u32),
|
|
(F_MAIN_HAND + 1, (SWORD >> 32) as u32),
|
|
(F_PLAYER_BYTES_3, 8 << 24),
|
|
// A 2.70 s main hand and no off hand, which
|
|
// `the_players_swing_speed_reaches_the_verb_through_the_feed` reads back through
|
|
// the verb. The other tests here price repairs and do not look at it.
|
|
(F_BASE_ATTACK_TIME, 2700),
|
|
])),
|
|
));
|
|
let mut sword = crate::items::test_template("Sword");
|
|
(sword.class, sword.subclass, sword.quality, sword.item_level) = (2, 7, 1, 20);
|
|
app.world_mut()
|
|
.resource_mut::<Items>()
|
|
.insert_template(SWORD_ENTRY, Some(sword));
|
|
crate::items::test_spawn_item(
|
|
app.world_mut(),
|
|
SWORD,
|
|
ObjectFields::from_pairs(&[
|
|
(F_OBJECT_ENTRY, SWORD_ENTRY),
|
|
(F_ITEM_DURABILITY, 35),
|
|
(F_ITEM_MAXDURABILITY, 75),
|
|
]),
|
|
false,
|
|
);
|
|
let script = UiScript::new().unwrap();
|
|
// A tooltip of our own, so the other reader of the fed cost — `SetInventoryItem`'s third
|
|
// return, which stock `PaperDollFrame.lua` prints its "Repair Cost" line off — is checked
|
|
// against the same feed in the same test.
|
|
script
|
|
.run(
|
|
r#"
|
|
local owner = CreateFrame("Button", "RepairSlotOwner")
|
|
owner:SetWidth(10) owner:SetHeight(10) owner:SetPoint("CENTER", 0, 0)
|
|
TT = CreateFrame("GameTooltip", "TT")
|
|
TT:SetOwner(owner, "ANCHOR_RIGHT")
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
app.insert_non_send_resource(script);
|
|
app
|
|
}
|
|
|
|
/// What the VM answers for the main hand through both surfaces that read the fed cost.
|
|
fn quoted(app: &mut App) -> (i64, i64) {
|
|
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
|
(
|
|
script
|
|
.eval::<i64>("return GetInventoryItemRepairCost(16)")
|
|
.unwrap(),
|
|
script
|
|
.eval::<i64>(r#"local _, _, cost = TT:SetInventoryItem("player", 16) return cost"#)
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
/// A worn trinket's use cooldown reaches the doll feed, and arming one **opens the gate** —
|
|
/// which is the whole risk in this change. No descriptor moves when a cooldown starts, so
|
|
/// without `Cooldowns::feed_epoch` as a gate input the swirl would appear only on the next
|
|
/// frame some unrelated thing changed.
|
|
///
|
|
/// `GetInventoryItemCooldown` is a stock verb and answered `(0, 0, 1)` unconditionally before
|
|
/// this feed, so the character pane never drew a trinket's cooldown at all.
|
|
#[test]
|
|
fn arming_a_worn_trinkets_cooldown_opens_the_gate_and_reaches_the_stock_verb() {
|
|
use std::time::Instant;
|
|
|
|
const F_TRINKET_1: u16 = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD + 2 * 12;
|
|
const TRINKET: u64 = 0x4000_0000_0000_0d0d;
|
|
const TRINKET_ENTRY: u32 = 11_815;
|
|
const USE_SPELL: u32 = 11_392;
|
|
|
|
let (tx, _rx) = crossbeam_channel::unbounded();
|
|
let mut app = App::new();
|
|
let anchor = Instant::now();
|
|
app.init_resource::<CharFeedState>()
|
|
.init_resource::<PaperDollBooth>()
|
|
.init_resource::<PendingItemOps>()
|
|
.init_resource::<Items>()
|
|
.init_resource::<crate::net::GuidIndex>()
|
|
.init_resource::<crate::names::NameCache>()
|
|
.init_resource::<crate::ui_merchant::MerchantOpen>()
|
|
.init_resource::<crate::net::Reputations>()
|
|
.init_resource::<crate::spell::Cooldowns>()
|
|
.insert_resource(crate::ui_script::UiClock {
|
|
anchor,
|
|
ui_now: 100.0,
|
|
})
|
|
.insert_resource(NetCommands(tx));
|
|
app.world_mut().spawn((
|
|
SelfPlayer,
|
|
ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_TRINKET_1, TRINKET as u32),
|
|
(F_TRINKET_1 + 1, (TRINKET >> 32) as u32),
|
|
])),
|
|
));
|
|
let mut t = crate::items::test_template("Hand of Justice");
|
|
t.use_spell = Some(benilla_protocol::messages::ItemUseSpell {
|
|
spell_id: USE_SPELL,
|
|
cooldown_ms: 120_000,
|
|
category: 0,
|
|
category_cooldown_ms: 0,
|
|
});
|
|
app.world_mut()
|
|
.resource_mut::<Items>()
|
|
.insert_template(TRINKET_ENTRY, Some(t));
|
|
crate::items::test_spawn_item(
|
|
app.world_mut(),
|
|
TRINKET,
|
|
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, TRINKET_ENTRY)]),
|
|
false,
|
|
);
|
|
let mut script = UiScript::new().unwrap();
|
|
script.set_now(100.0);
|
|
app.insert_non_send_resource(script);
|
|
|
|
let ask = |app: &mut App| {
|
|
app.world_mut()
|
|
.non_send_resource_mut::<UiScript>()
|
|
.eval::<(f64, f64, i64)>(r#"return GetInventoryItemCooldown("player", 13)"#)
|
|
.unwrap()
|
|
};
|
|
// `add_systems` + `update`, not `run_system_once`: the gate's memo is a `Local`, and a
|
|
// one-shot run gets a fresh one every time — which makes `vm_reset` true on every call
|
|
// and opens the gate whatever its other inputs say. A test for a gate input has to run
|
|
// the system the way the schedule does, or it passes with that input deleted. (It did.)
|
|
app.add_systems(Update, feed_char);
|
|
app.update();
|
|
assert_eq!(ask(&mut app), (0.0, 0.0, 1), "nothing armed yet");
|
|
// A second frame with nothing moving, so the gate is genuinely shut before the arm.
|
|
app.update();
|
|
|
|
// The wire arms the item's use cooldown. Nothing about the item object changes.
|
|
app.world_mut()
|
|
.resource_mut::<crate::spell::Cooldowns>()
|
|
.apply_wire_item_cooldown(TRINKET_ENTRY, USE_SPELL, anchor);
|
|
app.update();
|
|
let (start, duration, enable) = ask(&mut app);
|
|
assert!(
|
|
(start - 100.0).abs() < 0.05 && duration > 0.0 && enable == 1,
|
|
"the armed cooldown reaches the stock verb on the frame it is armed, got \
|
|
{start} / {duration} / {enable}"
|
|
);
|
|
}
|
|
|
|
/// `UnitAttackSpeed("player")` answers the store's own `UNIT_FIELD_BASEATTACKTIME`, through
|
|
/// the real feed, on the frame the store arrives.
|
|
///
|
|
/// This is the one assertion the VM-side tests could not make, and the gap cost two sessions:
|
|
/// every swing-timer test seats the sheet with `set_player_combat_stats` and then asks, which
|
|
/// proves the *verb* and says nothing about whether anything ever calls the setter. The whole
|
|
/// `[noslamclip]` chain divides by this number — SP_SwingTimer latches it into `st_timerMax`
|
|
/// at `ADDON_LOADED` and CleveRoids computes its window from that — so a feed that never
|
|
/// pushed would leave the stand-in in place for the session and the macro's window wrong, with
|
|
/// nothing raised anywhere.
|
|
#[test]
|
|
fn the_players_swing_speed_reaches_the_verb_through_the_feed() {
|
|
// The store carries the wire's own `UNIT_FIELD_BASEATTACKTIME`: 2700 ms, no off hand.
|
|
let mut app = world_wearing_a_damaged_sword();
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let script = app.world().non_send_resource::<UiScript>();
|
|
assert_eq!(
|
|
script
|
|
.eval::<f64>(r#"return (UnitAttackSpeed("player"))"#)
|
|
.expect("UnitAttackSpeed answers"),
|
|
2.7,
|
|
"the store's own base attack time has to reach Lua, not the `VANILLA_SWING_MS` \
|
|
stand-in `UnitCombatStats::default()` holds before the first push"
|
|
);
|
|
assert!(
|
|
script
|
|
.eval::<bool>(r#"return select(2, UnitAttackSpeed("player")) == nil"#)
|
|
.expect("the second return"),
|
|
"no off-hand weapon is `nil`, not a number — SP_SwingTimer's `isDualWield()` is \
|
|
exactly this read, and a false positive sends its timer resets to the wrong hand"
|
|
);
|
|
}
|
|
|
|
/// The equipped band's repair quote is `0x4faf30`'s own number, not 0 — which is what the
|
|
/// stock paper-doll tooltip's "Repair Cost" line and pfUI's durability panel both read. With
|
|
/// no vendor open it is the full list price, because `0x4faf30` takes a discount only once it
|
|
/// resolves a merchant guid.
|
|
#[test]
|
|
fn an_equipped_items_repair_quote_is_the_full_price_with_no_merchant_open() {
|
|
let full = repair_tables().repair_cost(40, 20, 1, 2, 7, 0.0);
|
|
assert!(full > 0, "the fixture prices this sword at all");
|
|
let mut app = world_wearing_a_damaged_sword();
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
assert_eq!(
|
|
quoted(&mut app),
|
|
(i64::from(full), i64::from(full)),
|
|
"both the slot getter and `SetInventoryItem`'s third return quote the list price"
|
|
);
|
|
}
|
|
|
|
/// Opening a vendor the player earns a discount at re-prices every worn slot in one step, and
|
|
/// `feed_char`'s gate has to notice: the discount is no descriptor field of ours, so nothing
|
|
/// else in the gate moves on the frame the merchant window opens. The quote then agrees with
|
|
/// what the merchant window will charge, because both are the one `0x4faf30`.
|
|
#[test]
|
|
fn opening_a_merchant_re_prices_the_worn_band_at_the_vendors_discount() {
|
|
// Honor rank 8 at a `UNIT_FLAG_PVP` vendor: the two 0.05 honor steps (`0x612bcf`,
|
|
// `0x612bd7`). No reputation term — there is no `Faction.dbc` here, so the reaction reads
|
|
// Neutral and the 0.1 leg stays shut, which keeps this test off the install.
|
|
let steps = f64::from(0.05f32) + f64::from(0.05f32);
|
|
let full = repair_tables().repair_cost(40, 20, 1, 2, 7, 0.0);
|
|
let discounted = repair_tables().repair_cost(40, 20, 1, 2, 7, steps);
|
|
assert!(
|
|
discounted > 0 && discounted < full,
|
|
"the case discriminates: {discounted} of {full}"
|
|
);
|
|
|
|
// Scheduled, not `run_system_once`: a once-run system is a fresh system every call, so
|
|
// every `Changed<>` filter reads true and the gate is open whatever its inputs say. Only a
|
|
// system that keeps its identity across frames can show that the *discount* is what
|
|
// re-opened it.
|
|
let mut app = world_wearing_a_damaged_sword();
|
|
app.add_systems(Update, feed_char);
|
|
app.update();
|
|
assert_eq!(quoted(&mut app).0, i64::from(full));
|
|
// A frame where nothing at all moved: the gate is shut, which is what makes the next one
|
|
// evidence.
|
|
app.update();
|
|
assert_eq!(quoted(&mut app).0, i64::from(full));
|
|
|
|
// The vendor streams and its window opens. Nothing about the sword moved — no item field,
|
|
// no player descriptor write, no template, no pending op. The discount is the only input
|
|
// that can carry this.
|
|
let vendor = app
|
|
.world_mut()
|
|
.spawn((
|
|
crate::net::Guid(VENDOR),
|
|
// `UNIT_FIELD_FLAGS` with `UNIT_FLAG_PVP` (bit 12), the honor steps' own gate.
|
|
ObjectStore(ObjectFields::from_pairs(&[(
|
|
benilla_protocol::field::FIELD_UNIT_FLAGS,
|
|
0x1000,
|
|
)])),
|
|
))
|
|
.id();
|
|
app.world_mut()
|
|
.resource_mut::<crate::net::GuidIndex>()
|
|
.0
|
|
.insert(VENDOR, vendor);
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_merchant::MerchantOpen>()
|
|
.open(VENDOR, Vec::new());
|
|
app.update();
|
|
assert_eq!(
|
|
quoted(&mut app),
|
|
(i64::from(discounted), i64::from(discounted)),
|
|
"the open vendor's discount came off both quotes"
|
|
);
|
|
|
|
// And closing it puts the list price back.
|
|
app.world_mut()
|
|
.resource_mut::<crate::ui_merchant::MerchantOpen>()
|
|
.clear();
|
|
app.update();
|
|
assert_eq!(quoted(&mut app).0, i64::from(full));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bank_bag_slot_filling_fires_playerbankslots_changed() {
|
|
let mut app = world_with_bank_bag(Some(BAG));
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let events = seen(&mut app);
|
|
assert!(
|
|
events.contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()),
|
|
"the repaint event, and it carries NO argument — `0x703e50` pushes none, and the \
|
|
recorder stringifies the absent arg1 as nil. Got {events:?}"
|
|
);
|
|
assert!(
|
|
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
|
"and the doll's, unchanged, got {events:?}"
|
|
);
|
|
}
|
|
|
|
/// A bag's own field moving is the item-object path (`0x4c7180`), which pushes `"player"`.
|
|
#[test]
|
|
fn a_bank_bags_own_field_moving_fires_the_item_object_producer() {
|
|
let mut app = world_with_bank_bag(Some(BAG));
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
assert!(seen(&mut app).contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string()));
|
|
|
|
// The same bag guid, restacked: only the item object moved.
|
|
restream(
|
|
&mut app,
|
|
BAG,
|
|
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY), (F_ITEM_STACK_COUNT, 3)]),
|
|
);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let events = seen(&mut app);
|
|
assert!(
|
|
events.contains(&"PLAYERBANKSLOTS_CHANGED player".to_string()),
|
|
"the item path pushes the token, got {events:?}"
|
|
);
|
|
}
|
|
|
|
/// Identical bags push identical views; the reference watches the guids and fires twice.
|
|
#[test]
|
|
fn two_identical_bags_swapped_between_bank_bag_slots_still_announce() {
|
|
let mut app = world_with_bank_bag(Some(BAG));
|
|
// A second, byte-identical bag in bank bag slot 2.
|
|
const BAG2: u64 = 0x4000_0000_0000_0abd;
|
|
// Rewritten wholesale: `ObjectFields` has no setter.
|
|
let set_slots = |app: &mut App, a: u64, b: u64| {
|
|
let mut q = app
|
|
.world_mut()
|
|
.query_filtered::<&mut ObjectStore, With<SelfPlayer>>();
|
|
let mut store = q.single_mut(app.world_mut()).unwrap();
|
|
*store = ObjectStore(ObjectFields::from_pairs(&[
|
|
(F_BANK_BAG_1, a as u32),
|
|
(F_BANK_BAG_1 + 1, (a >> 32) as u32),
|
|
(F_BANK_BAG_1 + 2, b as u32),
|
|
(F_BANK_BAG_1 + 3, (b >> 32) as u32),
|
|
]));
|
|
};
|
|
set_slots(&mut app, BAG, BAG2);
|
|
crate::items::test_spawn_item(
|
|
app.world_mut(),
|
|
BAG2,
|
|
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, BAG_ENTRY)]),
|
|
true,
|
|
);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let _ = seen(&mut app);
|
|
|
|
// Exchange them. Every pushed view is equal before and after.
|
|
set_slots(&mut app, BAG2, BAG);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let events = seen(&mut app);
|
|
assert_eq!(
|
|
events
|
|
.iter()
|
|
.filter(|e| *e == "PLAYERBANKSLOTS_CHANGED nil")
|
|
.count(),
|
|
2,
|
|
"one argless event per slot whose guid moved, got {events:?}"
|
|
);
|
|
}
|
|
|
|
/// A lock flip is `ITEM_LOCK_CHANGED`'s, not a slot change.
|
|
#[test]
|
|
fn a_lock_flip_alone_does_not_fire_the_repaint_event() {
|
|
let mut app = world_with_bank_bag(Some(BAG));
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let _ = seen(&mut app);
|
|
|
|
// The same bag, now locked by an outbound move.
|
|
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
|
EQUIPMENT_BAG,
|
|
BANK_BAG_LIVE_FIRST,
|
|
BAG,
|
|
1,
|
|
)]);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let events = seen(&mut app);
|
|
assert!(
|
|
events.contains(&"UNIT_INVENTORY_CHANGED player".to_string()),
|
|
"the snapshot did move — `locked` is part of it, got {events:?}"
|
|
);
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| e.starts_with("PLAYERBANKSLOTS_CHANGED")),
|
|
"…but the ITEM did not change, so the repaint event stays quiet, got {events:?}"
|
|
);
|
|
}
|
|
|
|
/// The frame a lock clears, `!pending.is_empty()` already reads false;
|
|
/// [`PendingItemOps::epoch`] is what still runs the feed.
|
|
#[test]
|
|
fn the_frame_a_lock_clears_still_repushes_the_slot() {
|
|
let mut app = world_with_bank_bag(Some(BAG));
|
|
app.world_mut().resource_mut::<PendingItemOps>().add([(
|
|
EQUIPMENT_BAG,
|
|
BANK_BAG_LIVE_FIRST,
|
|
BAG,
|
|
1,
|
|
)]);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
let _ = seen(&mut app);
|
|
assert!(
|
|
app.world_mut()
|
|
.non_send_resource_mut::<UiScript>()
|
|
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
|
.unwrap(),
|
|
"in flight, the slot reads locked"
|
|
);
|
|
|
|
// A refusal: the lock clears without any object moving.
|
|
let cleared = app
|
|
.world_mut()
|
|
.resource_mut::<PendingItemOps>()
|
|
.clear_by_failure(BAG);
|
|
assert_eq!(cleared, vec![(EQUIPMENT_BAG, BANK_BAG_LIVE_FIRST)]);
|
|
app.world_mut().run_system_once(feed_char).unwrap();
|
|
assert!(
|
|
!app.world_mut()
|
|
.non_send_resource_mut::<UiScript>()
|
|
.eval::<bool>(r#"return IsInventoryItemLocked(64) and true or false"#)
|
|
.unwrap(),
|
|
"the unlock has to reach the VM in the same frame it happened"
|
|
);
|
|
}
|
|
}
|