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:
Sam
2026-09-04 20:54:27 +07:00
co-authored by Claude Fable 5.1
parent 72e78ebc92
commit 2f7cfcabf5
107 changed files with 3407 additions and 3490 deletions
+34 -1
View File
@@ -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
+180 -2
View File
@@ -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,
+11
View File
@@ -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,
+2 -2
View File
@@ -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::{
+14
View File
@@ -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(),
+3 -3
View File
@@ -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]