the action bars are the reference's own, and a gift can be wrapped
- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938) - a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943) - a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942) - the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -273,7 +273,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// UseAction(action [, checkCursor [, onSelf]]). **`onSelf` is the self-cast modifier** — the
|
||||
// third argument `ActionBar.xml`'s `ActionButtonUp(id, onSelf)` has always forwarded and this
|
||||
// third argument stock `ActionButtonUp(id, onSelf)` forwards on its main-bar branch and this
|
||||
// host used to drop on the floor, which is why 1.12's twelve `SELFACTIONBUTTON` bindings
|
||||
// (`ALT-1`…`ALT-=`) had no home. It rides out on [`ActionUse::on_self`] and the app's cast
|
||||
// resolver reads it. `checkCursor` TRUTHY (numeric nonzero — the
|
||||
@@ -327,6 +327,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `ChangeActionBarPage()` — four instructions in the reference (`0x4e7650`: `mov ecx,0xd4;
|
||||
// call FrameScript_SignalEvent; xor eax,eax; ret` — wow-re `action-bar-toggles.md` §6, `ui.md`):
|
||||
// it fires `ACTIONBAR_PAGE_CHANGED` and does NOTHING else. The page is FrameXML state
|
||||
// (`CURRENT_ACTIONBAR_PAGE`, which `ActionBar_PageUp/Down` and the SHIFT-n bindings write before
|
||||
// calling this), the buttons repaint from `ActionButton_OnEvent`, and the up-arrow's OnEvent
|
||||
// restamps the numeral. Synchronous, like the reference's `SignalEvent` (1938; the pattern is
|
||||
// `UpdateSpells` → `SPELLS_CHANGED`, 1924). Zero return values on every path.
|
||||
g.set(
|
||||
"ChangeActionBarPage",
|
||||
lua.create_function(|lua, ()| {
|
||||
super::tick::fire_event_into(lua, "ACTIONBAR_PAGE_CHANGED", Vec::new());
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── The dynamic-state read API (decision 0137 phase 4): the reference ActionButton.lua's
|
||||
// whole input surface, answered from the app-pushed per-action state. Return conventions are
|
||||
// the 1.12 API's own (1/nil booleans; IsActionInRange's nil/0/1 tri-state) — the transcribed
|
||||
@@ -465,6 +480,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_action_bar_page_fires_the_event_and_nothing_else() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"CURRENT_ACTIONBAR_PAGE = 3 \
|
||||
local f = CreateFrame('Frame') f:RegisterEvent('ACTIONBAR_PAGE_CHANGED') \
|
||||
f:SetScript('OnEvent', function() SEEN = event .. ':' .. CURRENT_ACTIONBAR_PAGE end) \
|
||||
N = table.getn({ChangeActionBarPage()})",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SEEN").unwrap(),
|
||||
"ACTIONBAR_PAGE_CHANGED:3",
|
||||
"the event fires synchronously, and the page is whatever FrameXML wrote"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return N").unwrap(), 0, "zero return values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_snapshot_reads_and_use_queues() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -287,6 +287,11 @@ pub struct InvSlotView {
|
||||
/// and the reference renders all 7 from it — but a 1.12 server fills only PERM and TEMP, so in
|
||||
/// practice that is what an inspect hover shows.
|
||||
pub enchants: Vec<super::EnchantView>,
|
||||
/// **The instance's remaining LIFETIME in milliseconds** — the doll twin of
|
||||
/// [`super::container::ContainerSlot::duration_ms`]. `None` = no timer. In practice an
|
||||
/// equipped duration item is rare (the holiday masks are the shipped case), but the line law
|
||||
/// is one law and the doll hover runs the same builder. Decision 1933.
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// The inventory-slot snapshot: index 0 = ammo, 1..=19 the equipment slots, 20..=23 the four
|
||||
|
||||
@@ -105,6 +105,13 @@ pub struct ContainerSlot {
|
||||
/// hands over the row's name plus the three facts the line law needs to place and paint it.
|
||||
/// Empty = unenchanted, or the enchant DBC never loaded. Decisions 0915/0920.
|
||||
pub enchants: Vec<super::EnchantView>,
|
||||
/// **The instance's remaining LIFETIME in milliseconds** — a duration-limited item counting
|
||||
/// down to its own destruction (a conjured stone, a holiday gift, a timed quest item). Its
|
||||
/// only feed is `SMSG_ITEM_TIME_UPDATE`, parked as an absolute deadline and recomputed on
|
||||
/// read ([`crate::items::Items::duration_remaining_display_ms`] in benilla-app), floored to
|
||||
/// the whole second so the snapshot moves once a second rather than every frame.
|
||||
/// `None` = no timer, which is nearly every item. Decision 1933.
|
||||
pub duration_ms: Option<u64>,
|
||||
/// The petition this item names, when it is a signable charter — **line 3 of the tooltip's
|
||||
/// emission law**, between the NAME and the green `ITEM_SIGNABLE` line.
|
||||
///
|
||||
@@ -263,6 +270,47 @@ impl super::UiScript {
|
||||
std::mem::take(&mut self.model_mut().container_destroys)
|
||||
}
|
||||
|
||||
/// **Arm the gift-wrap cursor** on the paper at `(bag, slot)` — the app's call when a
|
||||
/// right-click takes the reference's begin-wrap arm (`ItemInfo::begins_gift_wrap`).
|
||||
///
|
||||
/// Three acts, exactly the three `0x5edea0` performs (wow-re
|
||||
/// `object-layer/scratch/gift-wrap-law.md`): the paper's slot reads **locked**, the displayed
|
||||
/// cursor becomes **mode 2** (`Interface\Cursor\Cast.blp` — the same art the cast cursor
|
||||
/// uses, table `0x853b88` slot 2), and **nothing goes on the wire**. What completes it is the
|
||||
/// next left-click on a container slot. Decision 1934.
|
||||
pub fn arm_gift_wrap(&mut self, bag: i64, slot: u32) {
|
||||
let mut model = self.model_mut();
|
||||
model.pending_wrap = Some(PendingWrap { bag, slot });
|
||||
model.ui_cursor = Some(UiCursorMode::Cast);
|
||||
model.ui_cursor_dirty = true;
|
||||
cursor::queue_lock_changed(&mut model, bag, slot);
|
||||
}
|
||||
|
||||
/// **Cancel an armed gift wrap and nothing else** — disarm, unlock the paper, reset the
|
||||
/// cursor. Deliberately narrower than `ClearCursor`, which also drops any held payload:
|
||||
/// what wow-re records is that a right-click "uses the item and cancels the wrap", not by
|
||||
/// which of the two routes, so this touches only the half the finding actually names.
|
||||
pub fn cancel_gift_wrap(&mut self) -> Option<PendingWrap> {
|
||||
let mut model = self.model_mut();
|
||||
let wrap = model.pending_wrap.take()?;
|
||||
cursor::queue_lock_changed(&mut model, wrap.bag, wrap.slot);
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
Some(wrap)
|
||||
}
|
||||
|
||||
/// Is a gift wrap armed, and on which slot? Read by the app to decide whether a right-click
|
||||
/// is cancelling one.
|
||||
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
|
||||
self.model_ref().pending_wrap
|
||||
}
|
||||
|
||||
/// Drain the `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued — the app
|
||||
/// resolves each pair to the wire's bag/slot addressing and sends `CMSG_WRAP_ITEM`.
|
||||
pub fn take_container_wraps(&mut self) -> Vec<(i64, u32, i64, u32)> {
|
||||
std::mem::take(&mut self.model_mut().container_wraps)
|
||||
}
|
||||
|
||||
/// The mode the last FrameXML cursor call armed — `None` after a `ResetCursor`. This is the
|
||||
/// *value* of the most recent write; whether a write happened at all is
|
||||
/// [`Self::take_cursor_write`], and that is what the app acts on (decision 1061).
|
||||
@@ -285,6 +333,19 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The armed gift wrap** — which piece of wrapping paper the next container click will spend.
|
||||
///
|
||||
/// The reference holds this as globals beside the displayed-cursor mode rather than as a cursor
|
||||
/// *payload*; the distinction is load-bearing and is why this is its own type instead of a
|
||||
/// [`cursor::CursorPayload`] variant (see [`super::Model::pending_wrap`]).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct PendingWrap {
|
||||
/// The paper's container id, in the same bag space every cursor verb speaks.
|
||||
pub bag: i64,
|
||||
/// The paper's 1-based slot within that container.
|
||||
pub slot: u32,
|
||||
}
|
||||
|
||||
/// A **displayed-cursor override** the FrameXML cursor family arms (wow-re cursor-system.md §7): the
|
||||
/// single "displayed mode" (`0xbe2c2c`) the real client swaps to while a UI element wants a non-base
|
||||
/// cursor, restored to the base mode by `ResetCursor`. The app maps each to the matching
|
||||
@@ -330,6 +391,49 @@ pub enum UiCursorMode {
|
||||
///
|
||||
/// Returns whether the caller should repaint (the source-slot lock or the held payload changed).
|
||||
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool {
|
||||
// **The gift-wrap completion, ahead of the whole gesture** (decision 1934). `0x4f9b30` is the
|
||||
// *only* entry to the wrap sender `0x5edfc0` image-wide — which is why the paper doll cannot
|
||||
// finish a wrap and an equipped item is therefore unwrappable — and it takes this branch
|
||||
// before it looks at the cursor at all.
|
||||
if let Some(wrap) = model.pending_wrap {
|
||||
// An empty or unresolved slot **bails and leaves the wrap armed** — the reference's own
|
||||
// edge. Nothing is spent and the cursor keeps its mode, so the next click can still land
|
||||
// on a real item.
|
||||
if !model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.is_some_and(|s| s.item_id != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// **The paper has to still be there.** `0x5edfef` bails when the stashed gift GUID no
|
||||
// longer resolves, and — the edge worth transcribing — it then sends *and clears*
|
||||
// nothing: the wrap stays armed and the paper's slot stays locked. So this returns
|
||||
// without touching either.
|
||||
if !model
|
||||
.containers
|
||||
.get(&wrap.bag)
|
||||
.and_then(|c| c.slots.get(&wrap.slot))
|
||||
.is_some_and(|s| s.item_id != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// No eligibility test of any kind. The wrap arm reads each item's `ITEM_FIELD_CONTAINED`
|
||||
// and nothing else — not flags, not stack count, not bind state — and the client ships six
|
||||
// `ERR_CANT_WRAP_*` strings for the server to answer with (`SMSG_INVENTORY_CHANGE_FAILURE`
|
||||
// reasons 43-48). Same law as the openable click (decision 0896): gating locally would eat
|
||||
// the click in silence and cost the player their error line.
|
||||
model.pending_wrap = None;
|
||||
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
|
||||
// The paper unlocks and the cursor resets **immediately**, not on the server's answer:
|
||||
// `0x5edfc0` calls `0x5eded0` at `0x5ee0aa`, right after the send. The target is never
|
||||
// locked at all.
|
||||
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
return true;
|
||||
}
|
||||
match model.cursor.take() {
|
||||
None => {
|
||||
let picked = model
|
||||
@@ -653,7 +757,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let held = matches!(
|
||||
&model.cursor,
|
||||
Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot
|
||||
);
|
||||
) ||
|
||||
// An armed gift wrap locks its paper for as long as it stands (`0x4953e0` at
|
||||
// `0x5edeb5`) — the same dimming a held item's source slot shows, and released by
|
||||
// the send or by any cancel rather than by the server (decision 1934).
|
||||
matches!(
|
||||
&model.pending_wrap,
|
||||
Some(w) if w.bag == bag && w.slot == slot
|
||||
);
|
||||
(
|
||||
model
|
||||
.containers
|
||||
@@ -858,7 +969,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ContainerMove, ContainerSlot, ContainerState};
|
||||
use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode};
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn backpack() -> ContainerState {
|
||||
@@ -866,6 +977,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
@@ -903,6 +1015,71 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The gift-wrap state machine** (decision 1934), all four of its edges. Armed by the app's
|
||||
/// right-click on a wrapper, the arm locks the paper and paints cursor mode 2 and sends
|
||||
/// nothing; the next LEFT-click on a container slot spends it; an empty slot bails and leaves
|
||||
/// it armed; and any `ClearCursor` cancels it.
|
||||
#[test]
|
||||
fn an_armed_gift_wrap_spends_on_the_next_container_click() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_container(0, Some(backpack()));
|
||||
// The arm: nothing on the wire, the paper reads locked, the cursor is mode 2, and the
|
||||
// held-item predicate stays FALSE — this is a cursor mode, not a payload.
|
||||
s.arm_gift_wrap(0, 1);
|
||||
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
|
||||
assert!(s.take_container_wraps().is_empty(), "arming sends nothing");
|
||||
assert_eq!(s.take_cursor_write(), Some(Some(UiCursorMode::Cast)));
|
||||
assert!(
|
||||
s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
|
||||
.unwrap(),
|
||||
"the paper dims while the wrap stands"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return CursorHasItem()").unwrap(),
|
||||
"the wrap writes no payload, so every stock CursorHasItem() gate stays shut"
|
||||
);
|
||||
// A click on an EMPTY slot bails and leaves the wrap armed — the reference's own edge.
|
||||
s.eval::<()>("PickupContainerItem(0, 9)").unwrap();
|
||||
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
|
||||
assert!(s.take_container_wraps().is_empty());
|
||||
// A click on a real item spends it: the paper's pair leads, as the wire does.
|
||||
s.eval::<()>("PickupContainerItem(0, 4)").unwrap();
|
||||
assert_eq!(s.take_container_wraps(), vec![(0, 1, 0, 4)]);
|
||||
assert_eq!(s.gift_wrap_armed(), None);
|
||||
assert_eq!(
|
||||
s.take_cursor_write(),
|
||||
Some(None),
|
||||
"the cursor resets on the send, not on the server's answer"
|
||||
);
|
||||
assert!(
|
||||
s.cursor_payload().is_none(),
|
||||
"the completing click is spent on the wrap, not on picking the target up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Any `ClearCursor` cancels an armed wrap — the reference tests it FIRST inside
|
||||
/// `ClearCursor 0x495190` and does so ungated by either parameter, so all 70 of its call
|
||||
/// sites cancel (decision 1934).
|
||||
#[test]
|
||||
fn clear_cursor_cancels_an_armed_gift_wrap() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_container(0, Some(backpack()));
|
||||
s.arm_gift_wrap(0, 1);
|
||||
let _ = s.take_cursor_write();
|
||||
s.eval::<()>("ClearCursor()").unwrap();
|
||||
assert_eq!(s.gift_wrap_armed(), None);
|
||||
assert_eq!(s.take_cursor_write(), Some(None), "and the mode goes back");
|
||||
assert!(
|
||||
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
|
||||
.unwrap(),
|
||||
"the paper unlocks with the cancel — the lock never waited on a server"
|
||||
);
|
||||
// …and the next container click is an ordinary pickup again.
|
||||
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
|
||||
assert!(s.take_container_wraps().is_empty());
|
||||
assert!(s.cursor_payload().is_some(), "a plain pickup, as before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_snapshot_reads() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -1032,6 +1209,7 @@ mod tests {
|
||||
state.slots.insert(
|
||||
5,
|
||||
ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
|
||||
@@ -321,6 +321,16 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
|
||||
/// item payload also un-locks its source slot; an already-empty cursor is not a transition (no
|
||||
/// event).
|
||||
pub(crate) fn clear_cursor(model: &mut Model) {
|
||||
// **An armed gift wrap dies with any cancel** (decision 1934), and this is the FIRST thing
|
||||
// the reference's `ClearCursor 0x495190` does — `0x5edf10` is its opening act and it is
|
||||
// **ungated by either parameter**, including `ClearCursor(0)`, the flavour that deliberately
|
||||
// keeps an ordinary held item's lock. So all 70 of its call sites cancel a wrap. The paper
|
||||
// unlocks and the cursor mode goes back to the base.
|
||||
if let Some(wrap) = model.pending_wrap.take() {
|
||||
queue_lock_changed(model, wrap.bag, wrap.slot);
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
}
|
||||
match model.cursor.take() {
|
||||
Some(CursorPayload::Item(item)) => {
|
||||
queue_cursor_update(model);
|
||||
@@ -741,6 +751,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
crate::script::container::ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
|
||||
@@ -286,6 +286,7 @@ mod tests {
|
||||
fn doll_slots() -> crate::script::InventorySlots {
|
||||
let mut slots: crate::script::InventorySlots = Default::default();
|
||||
slots[1] = Some(InvSlotView {
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -303,6 +304,7 @@ mod tests {
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[11] = Some(InvSlotView {
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -320,6 +322,7 @@ mod tests {
|
||||
enchants: Vec::new(),
|
||||
});
|
||||
slots[19] = Some(InvSlotView {
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -344,6 +347,7 @@ mod tests {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
@@ -487,6 +491,7 @@ mod tests {
|
||||
});
|
||||
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
|
||||
slots[20] = Some(InvSlotView {
|
||||
duration_ms: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
@@ -526,6 +531,7 @@ mod tests {
|
||||
slots.insert(
|
||||
3,
|
||||
ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
|
||||
@@ -167,8 +167,8 @@ pub use char_stats::{
|
||||
pub use chat_send::ChatSend;
|
||||
pub use chat_window::ChatWindowLook;
|
||||
pub use container::{
|
||||
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
|
||||
RandomPropertyView, UiCursorMode,
|
||||
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PendingWrap,
|
||||
PetitionSlotView, RandomPropertyView, UiCursorMode,
|
||||
};
|
||||
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
|
||||
pub use cursor::{
|
||||
|
||||
@@ -862,6 +862,18 @@ pub(crate) struct Model {
|
||||
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
|
||||
/// `CMSG_DESTROYITEM`.
|
||||
pub(crate) container_destroys: Vec<(i64, u32, u32)>,
|
||||
/// **The armed gift wrap** — the `(bag, slot)` of a piece of wrapping paper whose right-click
|
||||
/// took the reference's begin-wrap arm (`0x5edea0`: lock the paper, cursor mode 2, no packet).
|
||||
/// `None` = nothing armed, which is nearly always. Decision 1934.
|
||||
///
|
||||
/// Deliberately **not** a [`cursor::CursorPayload`]: the reference writes no payload global at
|
||||
/// all here, only the displayed-cursor mode, so `CursorHasItem()` stays nil and none of the
|
||||
/// thirteen stock `CursorHasItem()` gates open. Making it a payload would silently open every
|
||||
/// one of them — the same trap decision 1677's mode-5 note names for the vendor cursor.
|
||||
pub(crate) pending_wrap: Option<container::PendingWrap>,
|
||||
/// `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued, drained by the app
|
||||
/// into `CMSG_WRAP_ITEM`. The paper's pair leads, as the wire does.
|
||||
pub(crate) container_wraps: Vec<(i64, u32, i64, u32)>,
|
||||
/// The Lua-set **displayed-cursor override** (`ShowContainerSellCursor`/`ShowMerchantSellCursor`/
|
||||
/// `ShowBuybackSellCursor`/`ShowInspectCursor` set it, `ResetCursor` clears it; [`container`] +
|
||||
/// [`merchant`]) — the single "displayed mode" the real client keeps at `0xbe2c2c`, restored to
|
||||
@@ -1704,6 +1716,8 @@ impl Model {
|
||||
item_picks: Vec::new(),
|
||||
enchant_confirms: Vec::new(),
|
||||
container_destroys: Vec::new(),
|
||||
pending_wrap: None,
|
||||
container_wraps: Vec::new(),
|
||||
ui_cursor: None,
|
||||
ui_cursor_dirty: false,
|
||||
container_autoequips: Vec::new(),
|
||||
|
||||
@@ -51,9 +51,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// call in FrameXML and in the addon corpus, and it only bites where a region already carries
|
||||
// alpha < 1 — where it would surface as art going permanently translucent, which is a LOOK and
|
||||
// therefore the director's call, not a quiet correction to fold into an unrelated slice. The
|
||||
// known live case is the action bar's grid ring, and `ActionBar.xml`'s
|
||||
// BenillaActionButton_SetRing is already written to be correct under BOTH readings (it passes
|
||||
// the fourth argument explicitly), so closing this cannot silently change that file.
|
||||
// known live case is the action bar's grid ring: stock `ActionButton_ShowGrid` passes the
|
||||
// fourth argument explicitly (`SetVertexColor(1.0, 1.0, 1.0, 0.5)`), so closing this cannot
|
||||
// silently change that file.
|
||||
m.set(
|
||||
"SetVertexColor",
|
||||
lua.create_function(
|
||||
|
||||
@@ -64,6 +64,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
duration_ms: None,
|
||||
petition: None,
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
|
||||
@@ -91,7 +91,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// Dropping every anchor is a retarget to the EMPTY target set, and it
|
||||
// names its node like any other (decision 1625). It matters here more
|
||||
// than anywhere: this arm is `GameTooltip_SetDefaultAnchor`'s, which is
|
||||
// the path EVERY action-bar hover takes (`ActionBar.xml:510`, the
|
||||
// the path EVERY action-bar hover takes (stock `ActionButton_SetTooltip`, the
|
||||
// `UberTooltips` default; the stance, pet and bonus bars the same) —
|
||||
// while a bag slot anchors straight to its button and never comes
|
||||
// through here. So a conservative touch on this line was a whole-graph
|
||||
|
||||
@@ -414,6 +414,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// show. Left `false` deliberately — inventing a selector we have
|
||||
// not read would be the §4 trade, and there is nothing to gain.
|
||||
openable_source: false,
|
||||
duration_ms: s.duration_ms,
|
||||
},
|
||||
)
|
||||
});
|
||||
@@ -460,7 +461,18 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Integer(1),
|
||||
Value::Nil,
|
||||
// `hasCooldown` — **broader than its name**, and this is the one arm of it we can
|
||||
// answer. The builder's Lua return is `[ebp-0x38]`, set by FOUR sites, only one of
|
||||
// which is the cooldown line: LOCKED_WITH_ITEM, the temporary-enchant countdown,
|
||||
// **the item's own duration line** (`0x52ce0d`), and ITEM_COOLDOWN_TIME. So a
|
||||
// duration-bearing item answers truthy here with no cooldown of any kind, and an
|
||||
// addon can see it (decision 1933's fold-back). The equipped-cooldown feed is
|
||||
// still the INTERIM above; this is the half that now exists.
|
||||
if inst.duration_ms.is_some() {
|
||||
Value::Boolean(true)
|
||||
} else {
|
||||
Value::Nil
|
||||
},
|
||||
Value::Integer(0),
|
||||
]))
|
||||
})?,
|
||||
@@ -518,6 +530,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// same clam mid-cooldown would show ITEM_COOLDOWN_TIME instead
|
||||
// (that line has no feed here yet — a separate, pre-existing gap).
|
||||
openable_source: !has_cd,
|
||||
duration_ms: s.duration_ms,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -564,7 +577,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Boolean(has_cd),
|
||||
// Not just the cooldown: the builder's return is set by four line sites, and the
|
||||
// item's own duration line is one of them (see `SetInventoryItem`'s note above,
|
||||
// decision 1933's fold-back).
|
||||
Value::Boolean(has_cd || inst.duration_ms.is_some()),
|
||||
Value::Integer(0),
|
||||
]))
|
||||
})?,
|
||||
|
||||
@@ -70,6 +70,18 @@ pub(super) struct ItemInstance {
|
||||
/// "Cooldown remaining", the same item off cooldown shows `<Right Click to Open>`.**
|
||||
/// Decision 0896.
|
||||
pub openable_source: bool,
|
||||
/// **The instance's remaining LIFETIME in milliseconds** — a duration-limited item (a
|
||||
/// conjured stone, a holiday gift, a timed quest item) counting down to its own destruction.
|
||||
/// `None` = no timer, which is every ordinary item and every template/link source.
|
||||
///
|
||||
/// App-resolved from the client-local deadline `SMSG_ITEM_TIME_UPDATE` parks
|
||||
/// ([`crate::script::ContainerSlot::duration_ms`]), not from the instance's
|
||||
/// `ITEM_FIELD_DURATION` field: vmangos's own writer says the field is not what the client
|
||||
/// displays from — *"Though the client has the information in the item's data field, we have
|
||||
/// to send SMSG_ITEM_TIME_UPDATE to display the remaining time"* (`Item::SendTimeUpdate`,
|
||||
/// `Objects/Item.cpp:1094`) — which is the same split the temporary-enchant countdown one
|
||||
/// field up already takes (decisions 0920/1933).
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// The SET block's blank gold spacer — the reference's own literal `0x854b2c`, and it is **not**
|
||||
@@ -425,6 +437,17 @@ pub(super) fn render_view(
|
||||
WHITE,
|
||||
))?;
|
||||
}
|
||||
// **ITEM_DURATION `0x854bb4` — the item's own expiry countdown** (`0x52ce0d`, one of the four
|
||||
// sites that set the builder's `[ebp-0x38]` return, wow-re `tooltip-content-law.md` §E3's
|
||||
// census). It sits here by that address: after the durability precompute the note pins at
|
||||
// `0x52cd0e–0x52cd22` (law line 18) and well before `ITEM_COOLDOWN_TIME` at `0x52e140` (line
|
||||
// 23) — the numbered law list omits the line entirely, which is the gap decision 1933 names.
|
||||
// Formatted through the same `0x52fa50` ladder as the enchant countdown, with key prefix
|
||||
// `ITEM_DURATION`; unlike its siblings that prefix ships **no `_P1` plural twin**, so every
|
||||
// count reads "days"/"hrs" (`GlobalStrings.lua:2401-2404`).
|
||||
if let Some(ms) = inst.and_then(|i| i.duration_ms) {
|
||||
add((duration_left(ms), WHITE))?;
|
||||
}
|
||||
// Class/race lists — red when the player's own bit is absent (the usable ask).
|
||||
if v.allowable_class > 0
|
||||
&& (v.allowable_class & full_mask(&CLASS_NAMES)) != full_mask(&CLASS_NAMES)
|
||||
@@ -618,22 +641,56 @@ pub(super) fn render_view(
|
||||
/// Templates are the shipped enUS values (`Interface\FrameXML\GlobalStrings.lua:2406-2411`),
|
||||
/// inlined like every other string in this builder.
|
||||
fn enchant_time_left(name: &str, ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, 1) => format!("{name} (1 day)"),
|
||||
(TimeBucket::Days, n) => format!("{name} ({n} days)"),
|
||||
(TimeBucket::Hours, 1) => format!("{name} (1 hour)"),
|
||||
(TimeBucket::Hours, n) => format!("{name} ({n} hrs)"),
|
||||
(TimeBucket::Min, n) => format!("{name} ({n} min)"),
|
||||
(TimeBucket::Sec, n) => format!("{name} ({n} sec)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The item self-expiry line's text — the same `0x52fa50` bucket ladder as
|
||||
/// [`enchant_time_left`] (largest unit that fits; **ceil** in the day/hour/minute arms,
|
||||
/// **truncated** in the seconds arm), composed against key prefix `ITEM_DURATION`.
|
||||
///
|
||||
/// The one difference from its siblings is the absence of a plural split: `ITEM_DURATION_*` ships
|
||||
/// no `_P1` twin (`GlobalStrings.lua:2401-2404`), so a one-day timer reads "Duration: 1 days"
|
||||
/// exactly as the reference's own string table spells it.
|
||||
fn duration_left(ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, n) => format!("Duration: {n} days"),
|
||||
(TimeBucket::Hours, n) => format!("Duration: {n} hrs"),
|
||||
(TimeBucket::Min, n) => format!("Duration: {n} min"),
|
||||
(TimeBucket::Sec, n) => format!("Duration: {n} sec"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rung [`time_bucket`] picked — `0x52fa50`'s runtime key suffix, as a type.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum TimeBucket {
|
||||
Days,
|
||||
Hours,
|
||||
Min,
|
||||
Sec,
|
||||
}
|
||||
|
||||
/// `0x52fa50`'s bucket + count, shared by every countdown line the item builder emits (the
|
||||
/// temporary enchant, the item's own expiry, and — when it is fed — the cooldown remaining).
|
||||
/// The largest unit that fits wins (`>= 1 day` · `>= 1 h` · `>= 1 min` · else seconds); the count
|
||||
/// is **ceil** in the day/hour/minute arms (the `roundUp` argument, which all seven of the
|
||||
/// function's callers pass as 1) and plain **truncation** in the seconds arm.
|
||||
fn time_bucket(ms: u64) -> (TimeBucket, u64) {
|
||||
const SEC: u64 = 1_000;
|
||||
const MIN: u64 = 60 * SEC;
|
||||
const HOUR: u64 = 60 * MIN;
|
||||
const DAY: u64 = 24 * HOUR;
|
||||
let ceil = |unit: u64| ms.div_ceil(unit);
|
||||
match ms {
|
||||
_ if ms >= DAY => match ceil(DAY) {
|
||||
1 => format!("{name} (1 day)"),
|
||||
n => format!("{name} ({n} days)"),
|
||||
},
|
||||
_ if ms >= HOUR => match ceil(HOUR) {
|
||||
1 => format!("{name} (1 hour)"),
|
||||
n => format!("{name} ({n} hrs)"),
|
||||
},
|
||||
_ if ms >= MIN => format!("{name} ({} min)", ceil(MIN)),
|
||||
_ => format!("{name} ({} sec)", ms / SEC),
|
||||
_ if ms >= DAY => (TimeBucket::Days, ms.div_ceil(DAY)),
|
||||
_ if ms >= HOUR => (TimeBucket::Hours, ms.div_ceil(HOUR)),
|
||||
_ if ms >= MIN => (TimeBucket::Min, ms.div_ceil(MIN)),
|
||||
_ => (TimeBucket::Sec, ms / SEC),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +730,23 @@ mod tests {
|
||||
assert_eq!(t(0), "Rockbiter (0 sec)");
|
||||
}
|
||||
|
||||
/// The item self-expiry line shares the enchant countdown's ladder and differs in exactly one
|
||||
/// way: `ITEM_DURATION_*` ships **no `_P1` plural twin**, so a one-unit count reads the same
|
||||
/// plural spelling as any other ("1 days", "1 hrs") — the shipped enUS strings say so
|
||||
/// (`GlobalStrings.lua:2401-2404`), and inventing a singular here would be our own invention,
|
||||
/// not the reference's.
|
||||
#[test]
|
||||
fn item_duration_shares_the_ladder_but_not_the_plural() {
|
||||
assert_eq!(duration_left(1_900), "Duration: 1 sec"); // seconds truncate
|
||||
assert_eq!(duration_left(59_999), "Duration: 59 sec");
|
||||
assert_eq!(duration_left(60_000), "Duration: 1 min"); // minutes ceil
|
||||
assert_eq!(duration_left(241_000), "Duration: 5 min");
|
||||
assert_eq!(duration_left(3_600_000), "Duration: 1 hrs"); // no singular twin
|
||||
assert_eq!(duration_left(3_600_001), "Duration: 2 hrs");
|
||||
assert_eq!(duration_left(86_400_000), "Duration: 1 days");
|
||||
assert_eq!(duration_left(1_209_600_000), "Duration: 14 days");
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the standalone
|
||||
/// charges line and the enchant line's suffix).
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user