Files
benilla-octo/crates/benilla-ui/src/script/cursor/bar.rs
T
SamandClaude Fable 5.1 fb57052242 coins ride the cursor, and the trade window and combat text go stock
- coins ride the cursor: `MoneyFrame.lua`/`.xml` load off the player's chain, and the stock kit reads the cursor's coins at load, so the money cursor — payload mode 2, the copper at `[0xb4e2f0]` — is the engine's: `PickupPlayerMoney` puts coins on the cursor and the purse is never debited, and `CoinPickupFrame.xml`, the shift-click coin dialog nothing of ours had ever answered, joins; wow-re's round then set the law: the pickup raises on a non-number and refuses silently on zero, lost control and more than the purse; `AddTradeMoney` folds the coins into the offer as an absolute `CMSG_SET_TRADE_GOLD` gated on the purse; the send-mail setters and `GetCoinIcon` answer as the bytes do; the coinage-change reflex trims an offer the purse no longer covers; and the four send-mail pickup/drop verbs the stock table names do not exist in 5875, so they are not built (1962, 1965)
- the trade window is the reference's own file: `TradeFrame.xml` + `TradeFrame.lua` off the chain, ours deleted; `GameTooltip:SetTradePlayerItem`/`SetTradeTargetItem` (`0x5341e0`/`0x534410`) render the slot's item the way the loot slot's is, and `TRADE_PLAYER_ITEM_CHANGED`/`TRADE_TARGET_ITEM_CHANGED` — which the stock file repaints one slot on, and nothing had fired — fire per changed slot beside `TRADE_UPDATE`; the stock file brings the chain's own `MoneyInputFrame`, a TARGET_TRADE `SmallMoneyFrame`, `ItemButtonTemplate` slots and the enchant slot's own wording — "Item not yet modified" where ours had read "Will Not Be Traded"; the wire's enchant id is not carried into the slot yet, so the tooltip shows the item without its enchant (1966)
- the dialogs' verbs, as the bytes define them: nine of the ten stock `StaticPopup.lua` bindings 1960 had left named are built off wow-re's round with their packets, events and UIParent arms — the bare trade pair, `ForceLogout`, the instance-boot timer (whole seconds), the area spirit healer's accept/cancel/time, `AcceptBattlefieldPort` out of a three-slot queue `SMSG_BATTLEFIELD_STATUS` fills, `CancelMeetingStoneRequest`, and the pet trainer's pair as the talent wipe's twin, latch for latch — `SMSG_PET_UNLEARN_CONFIRM` latches and asks, `ConfirmPetUnlearn` sends behind the money gate, `CheckPetUntrainerDist` reads the leash; not built, named: the healer's proximity scan (`0x4923b0`: 20 yd to acquire, 22 to retain), so no healer is cached yet, and `ReplaceTradeEnchant`, which rides trade-slot enchant targeting this engine does not build (1963)
- the scrolling combat text is the reference's own `Blizzard_CombatText`, LoadOnDemand off the chain: our transcription is deleted, and the addon loads through `UIParentLoadAddOn` at the reference's own sites; the family's fourteen defaults move to the options file where the reference keeps them, because a LoadOnDemand addon cannot define the options that decide whether it loads (1964); and the Looking-for-Group pair is what the bytes define, correcting 1959's flag: `SetLookingForGroup` takes three (type, entry) pairs and a comment, packs each slot as `(type << 24) & entry` — an AND (`0x4e9713`), so every admissible input stores zero — and sends `CMSG_SET_LOOKING_FOR_GROUP` only when the commit changed something; `GetLookingForGroup` answers three nilable slot names then the comment, never 1|nil; and the stock 1.12.1 UI never calls either — both call sites sit in an XML comment (1961)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:58:15 +07:00

495 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The action bar (decision 0216 §7, slice 4; byte-verified 0218 §2/§4): `PickupAction`/
//! `PlaceAction` join the ONE payload space as the [`CursorAction`] arm. Two things set this
//! surface apart from bags/doll:
//!
//! - **The bar is client-authoritative and actions HOP** (0218 §4, `PlaceAction 0x4e62e0`) — the
//! opposite of the post-0218 item swap: placing onto an occupied slot puts the DISPLACED action
//! on the cursor rather than clearing, because there is no server round-trip to wait on (the
//! 120-slot table is ours; [`place_action`] IS the mutation, not a request for one).
//! - **The engine owns the payload + transition, the app owns the 120-table** (0216 §7's
//! ownership split): `model.actions` here is the engine's own OPTIMISTIC mirror of the app's
//! authoritative store, kept only so `HasAction`/`GetActionTexture`/&c. read right the instant a
//! local pickup/place happens, without waiting a frame for the app to re-feed. Every mutation
//! also queues `(lua id, packed)` onto [`Model::action_sets`] — the wire intent the app drains
//! into `CMSG_SET_ACTION_BUTTON`, one send per queued entry (0218 §4: a drag-swap is two sends,
//! never atomic — this module never coalesces them).
use mlua::Lua;
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
/// The GlobalStrings key the reference's passive-spell refusal shows — errorId `0x9e`'s entry in
/// the errorId→key table (`0xb4b498 + 0x9e*0x14 = 0xb4c0f0` ← key ptr `0x84133c`), i.e.
/// `ERR_PASSIVE_ABILITY` = "You can't put a passive ability in the action bar.". Queued onto
/// `Model::ui_errors` for the app's action feed to resolve and fire.
const PASSIVE_ON_BAR_ERROR: &str = "ERR_PASSIVE_ABILITY";
/// Pack `(kind, action)` into the wire's `u32` slot word (`kind<<24 | action`, decision 0216 §1).
fn pack(kind: u8, action: u32) -> u32 {
(u32::from(kind) << 24) | (action & 0x00FF_FFFF)
}
/// `PickupAction(id)` — the action-bar slot's shift-click/drag-start entry point (ref
/// `ActionBarFrame.xml:12-38`'s `IsShiftKeyDown()` fork, `OnDragStart`).
///
/// - **A payload is already held** → falls through to [`place_action`] (the reference's own
/// contract: a shift-click while carrying just places, `ActionButtonTemplate`'s `OnClick` never
/// special-cases it).
/// - **Empty cursor, an occupied slot** → picks it up: payload `Action { src_slot: id, kind,
/// action, texture }`, the slot removed from `model.actions` (optimistic — the app re-pushes an
/// agreeing snapshot once `action_sets` lands), and `action_sets.push((id, 0))` queued — picking
/// an action OFF the bar empties it immediately on the wire too (the classic drag-off).
/// - **Empty cursor, an empty slot** → no-op (nothing to pick up).
///
/// Returns whether the caller should repaint (mirrors the container/doll pickup contract).
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
if model.cursor.is_some() {
return place_action(model, id);
}
let Some(slot) = model.actions.get(&id) else {
return false;
};
let payload = CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
};
model.actions.remove(&id);
model.cursor = Some(CursorPayload::Action(payload));
model.action_sets.push((id, 0));
queue_cursor_update(model);
true
}
/// `PlaceAction(id)` — the action-bar slot's click-with-payload/`OnReceiveDrag` entry point (ref
/// `UseAction(id, checkCursor=1)`'s place fork, `ActionBarFrame.xml`'s `OnReceiveDrag`).
///
/// Every arm writes the held action into `model.actions[id]` optimistically and queues
/// `action_sets.push((id, packed))`; what happens to the cursor afterward is the byte-verified
/// divergence from every other surface (0218 §4): an OCCUPIED destination puts the DISPLACED
/// action on the cursor (referencing `id` as its new `src_slot` — the slot it can now be placed
/// FROM), an empty destination just clears.
///
/// - Payload **Action** → `(kind, action)` straight off the held payload.
/// - Payload **Item** → `packed = item_id | ITEM<<24`; the item came from a BAG and STAYS there —
/// a bar item action is a reference, not a move, so no container move is queued here (the app's
/// `drain_action_sets` never touches `container_moves` either).
/// - Payload **Spell** → `packed = spell_id | SPELL<<24` (the producer, `PickupSpell`, lands in
/// slice 5 — this arm already works once something populates the payload).
/// - **Empty cursor** → no-op.
///
/// Returns whether the caller should repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
// The two accept filters, byte-read (wow-re `action-item-slot.md` §5 — decision 0666). Both
// reject with a **bare return**: no store, no clear, no packet — mechanically identical to
// clicking with an empty cursor, and the refused payload STAYS on the cursor.
//
// - ITEM: placeable iff it has an on-use spell OR is equippable (`4e6571`–`4e6598`). There is
// no quality, bind, class/subclass, container or level test anywhere on that path — a BAG
// is placeable; a grey trade good with neither is silently refused.
// - SPELL: a passive (`Attributes & 0x40`) is refused (`4e63ad`), and — unlike the ITEM
// refusal, which is mute — it ALSO raises errorId `0x9e` through `CGGameUI::DisplayError`
// (`4e63ad: push 0x9e; call 0x496720`). That id resolves through the errorId→key table at
// base `0xb4b498`, stride `0x14`: entry `0xb4b498 + 0x9e*0x14 = 0xb4c0f0`, whose static-init
// `4861ff: mov [0xb4c0f0], 0x84133c` names [`PASSIVE_ON_BAR_ERROR`] — "You can't put a
// passive ability in the action bar." (The same arithmetic reproduces the independently
// recorded `ERR_ATTACK_MOUNTED` anchor: `0xa4` → `0xb4c168`.) Closes 0666's named
// divergence, which refused silently while the key was unpinned.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
Some(CursorPayload::Spell(s)) if s.passive => {
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
return false;
}
_ => {}
}
let Some(held) = model.cursor.take() else {
return false;
};
let placeable = match &held {
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
CursorPayload::Spell(s) => Some((ACTION_KIND_SPELL, s.spell_id, s.texture.clone())),
// Mode 8 — the one non-item/non-spell payload the reference's `PlaceAction` accepts
// (`action-item-slot.md` §5's payload table: pet actions and class abilities are refused,
// macros are not). It packs the bare macro id under the MACRO tag, exactly as the SPELL
// and ITEM arms pack theirs.
CursorPayload::Macro(m) => Some((ACTION_KIND_MACRO, m.index, m.texture.clone())),
// Mode 4 — the other half of that same table's refusal (decision 1010). A pet action has
// no `CMSG_SET_ACTION_BUTTON` encoding at all, so there is nothing to pack: the payload
// goes straight back on the cursor, where the pet bar can still take it.
CursorPayload::PetAction(_) => None,
// Mode 10 (decision 1677) — a stabled pet has no `CMSG_SET_ACTION_BUTTON` encoding either,
// so the action bar refuses it and it goes back on the cursor.
CursorPayload::StablePet(_) => None,
// Mode 2 (1962) — `PlaceAction`'s table takes modes 1, 2 and 8 … where its mode 2 is the
// BAR-slot pickup, not coins; the money payload has no encoding and goes back.
CursorPayload::Money(_) => None,
// Mode 5 — a vendor row is not a bar action. `PlaceAction`'s payload table takes modes
// 1, 2 and 8 only, and the merchant grab is none of them, so it goes back on the cursor
// where the vendor window can still take it.
CursorPayload::Merchant(_) => None,
};
let Some((kind, action, texture)) = placeable else {
model.cursor = Some(held);
return false;
};
let displaced = model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// The engine holds no item knowledge, so neither half of the Count pair can be
// answered here: the app's next-frame re-feed resolves the real bag count and the
// `IsConsumableAction` gate together, off the item template (decision 1301).
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
model.cursor = displaced.map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture,
})
});
queue_cursor_update(model);
true
}
/// Register the action-bar's cursor globals — top-level, matching the reference
/// (`PickupAction`/`PlaceAction` are not namespaced any more than `PickupContainerItem`'s cursor
/// siblings are).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"PickupAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_action(&mut model, id))
})?,
)?;
g.set(
"PlaceAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(place_action(&mut model, id))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::{ActionSlot, UiScript};
fn action_slot(texture: &str, kind: u8, action: u32) -> ActionSlot {
ActionSlot {
texture: Some(texture.into()),
kind,
action,
count: 0,
consumable: false,
}
}
#[test]
fn pickup_action_empties_the_slot_and_queues_a_clear() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
);
let (kind, id) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), id), ("action", 1));
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
#[test]
fn pickup_action_on_an_empty_slot_is_a_no_op() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(s.cursor_payload().is_none());
assert!(s.take_action_sets().is_empty());
}
#[test]
fn place_action_onto_empty_writes_the_slot_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 3,
kind: 0x00,
action: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(7)").unwrap());
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(7)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// 0x00<<24 | 133 == 133.
assert_eq!(s.take_action_sets(), vec![(7, 133)]);
}
/// The byte-verified divergence from bags/doll (0218 §4): placing onto an OCCUPIED action
/// slot HOPS the displaced action onto the cursor — two `action_sets` entries across the
/// gesture (the pickup's clear, then the place's write), never a container move.
#[test]
fn place_action_onto_occupied_hops_the_displaced_action() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(s.eval::<bool>("return PlaceAction(2)").unwrap());
// Slot 2 now shows the placed action (111).
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// The displaced action (222) is now the held payload, sourced from slot 2.
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), src), ("action", 2));
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Action(CursorAction {
src_slot: 2,
kind: 0x00,
action: 222,
texture: Some("Interface\\Icons\\Spell_B".into()),
}))
);
assert_eq!(s.take_action_sets(), vec![(2, 111)]);
// A hop is Some→Some: no HIDE+SHOW churn out of one gesture.
// (Exercised directly by the SHOWGRID/HIDEGRID test below.)
}
#[test]
fn place_action_item_payload_packs_the_item_kind_and_leaves_the_bag_untouched() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(s.cursor_payload().is_none());
// 0x80<<24 | 117.
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]);
assert!(
s.take_container_moves().is_empty(),
"a bar item action is a reference, not a move"
);
}
#[test]
fn place_action_spell_payload_packs_the_spell_kind() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(9)").unwrap());
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
}
#[test]
fn place_action_with_an_empty_cursor_is_a_no_op() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
assert!(s.take_action_sets().is_empty());
}
/// Shift-click while ALREADY holding just places (the reference's own `OnClick` never
/// special-cases it): `PickupAction` on a held cursor routes straight to [`super::place_action`].
#[test]
fn pickup_action_with_a_payload_held_falls_through_to_place() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0x00,
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
}
/// `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` fire on the cursor's None↔Some edges (decision
/// 0216 §7) — a pickup shows the grid, a place-onto-empty hides it, and a HOP (Some→Some)
/// fires neither (no HIDE+SHOW churn out of one gesture).
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
s.run(
r#"
shows, hides = 0, 0
local f = CreateFrame("Frame", "GridListener")
f:RegisterEvent("ACTIONBAR_SHOWGRID")
f:RegisterEvent("ACTIONBAR_HIDEGRID")
f:SetScript("OnEvent", function()
if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end
if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end
end)
"#,
)
.unwrap();
s.run("PickupAction(1)").unwrap(); // None -> Some: SHOW
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): neither
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("ClearCursor()").unwrap(); // Some -> None: HIDE
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
}
/// `PlaceAction`'s ITEM filter (decision 0666): an item with neither an on-use spell nor an
/// equip slot — a grey trade good — is refused, and the refusal is a **bare return**: nothing
/// stored, nothing sent, and the payload is still on the cursor afterwards.
#[test]
fn a_non_usable_non_equippable_item_is_refused_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: false, // no on-use spell, InventoryType 0
bag: 0,
slot: 1,
item_id: 2589, // Linen Cloth
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
assert!(s.take_action_sets().is_empty(), "no packet");
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Item(_))),
"a refused payload stays on the cursor — the reference never clears it"
);
assert!(
s.take_ui_errors().is_empty(),
"the ITEM refusal is MUTE — only the SPELL arm carries a DisplayError (`4e63ad`); a \
toast here would be ours, not the reference's"
);
}
/// The SPELL twin: a passive cannot go on the bar (`Attributes & 0x40`) — and unlike the item
/// arm this one SPEAKS, raising errorId `0x9e` = `ERR_PASSIVE_ABILITY` (see
/// [`super::place_action`]). The refusal and the toast are separate halves: the refusal is
/// still a bare return, so nothing is stored, nothing is sent, and the spell stays held.
#[test]
fn a_passive_spell_is_refused_with_the_refs_error_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 674, // Dual Wield, a passive
texture: Some("Interface\\Icons\\Ability_DualWield".into()),
passive: true,
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_action_sets().is_empty());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
assert_eq!(
s.take_ui_errors(),
vec![super::PASSIVE_ON_BAR_ERROR],
"the ref's errorId 0x9e toast — 'You can't put a passive ability in the action bar.'"
);
assert!(
s.take_ui_errors().is_empty(),
"…and the queue drains, so the toast fires once per refusal"
);
}
/// The other half of the same law: an ACTIVE spell places normally and raises nothing. Guards
/// the obvious over-correction — a toast on every spell drop.
#[test]
fn an_active_spell_places_without_an_error() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133, // Fireball
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_ui_errors().is_empty());
}
/// The positive control on the other side of both filters — a BAG (`InventoryType` 18, no
/// on-use spell) IS placeable, which is the one answer people find surprising.
#[test]
fn a_bag_is_placeable() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true, // equippable (BAG), even with no on-use spell
bag: 0,
slot: 1,
item_id: 4496, // Small Brown Pouch
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: vec![20, 21, 22, 23],
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
}