Files
benilla-octo/crates/benilla-ui/src/script/bind_confirm.rs
T
SamandClaude Fable 5 23b1a3e68b 71 more key commands, and the quest windows wear the reference's names
- the binding registry goes 130 → 201 of the client's 228 live commands: a coverage gate makes registered ∪ recorded-absent exactly the 1.12 surface, each absence naming the Lua globals whose arrival refutes it — which is how 37 rows whose mechanism had already landed came home (the keyring, the pet book, the raid tab, the six action-bar pages, both vertical multibars) — and then four mechanisms bring 71 more: the five camera views with the reference's default table and its sixteen camera CVars read out of the binary (NextView/PrevView do NOT wrap), self-cast riding UseAction's third argument with autoSelfCast a real CVar at last, the friendly target cone / assist / last-hostile as one cycler with a side (the two sides use different deadness tests on purpose, and assist neither requires an assistable target nor swings), and REPLY2 — reply to the last person you told; the friendly cone also stops borrowing the enemy side's in-combat score bonus, which would have shoved a healer targeting you to the head of every cycle
- the key names close their gaps: NUMPADEQUALS, F13-F24 and BUTTON6+ were keys the binding window listed and the file saved that the dispatcher could never press — named both ways now, with the namer and the codec structurally unable to disagree again; platform= becomes the OS gate it really is (off a Mac build the iTunes-remote category never even exists), and the Mac 1.12 slice settles that its F13 IS print-screen while F14/F15 map to UNKNOWN on purpose and stay unbindable; and the last-enemy stamp gains the four conjuncts it shipped without — you resolve, are not dead, not a ghost, not mounted, and the new target is alive-or-feigning — so TargetLastEnemy can no longer land somewhere the real client's never would
- the quest windows wear the reference's names: ~40 of the quest window's globals were ours (the reference's name usually sitting in the comment beside them), its two six-item reward pools merge into the reference's single ten-item run with the update function ported line-for-line, the four panels and the quest log take REAL scroll frames (the thumb drags now), the gossip window gets its greeting panel, the two mail panes' inert painted-at-rest scrollbars become real ones, the Skills detail pane nests as the reference has it, and nine private constants take their real names; the one that broke a feature detect: our QuestLogCount — a name 1.12 doesn't publish — made pfUI conclude it was running on TBC and die in that branch
- an EditBox is born with its constructor's five regions (the text, three selection quads and the caret), so GetRegions answers the reference's own count and authored regions start at index 6 — and a bare <NormalTexture/> now builds a live blank texture, since the element is what creates the region, not its file=; OpacityFrame lands, its first customer having arrived; and the equip / auto-equip / use bind confirms complete the soulbind arc — three different predicates with no quality leg (borrowing the loot arm's would have silenced the confirm on every white bind-on-equip item), Accept re-issuing the original action with the reference's suppress flag because 1.12 ships no confirm packet, and the soulstone use going through the same gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:16:49 +07:00

157 lines
7.3 KiB
Rust

//! The **item soulbind confirmations** other than the loot one — the Era API surface behind
//! `EQUIP_BIND` / `AUTOEQUIP_BIND` / `USE_BIND` (decision 1750). Three globals, no snapshot: the
//! question arrives as an event and the answer goes straight back out, exactly like
//! [`super::binder`] and [`super::duel`].
//!
//! ## What the three verbs mean, at the 1.12 bytes
//!
//! `EquipPendingItem(index)` `0x4898f0` and `CancelPendingEquip(index)` `0x489960` both delegate to
//! `0x5e1be0(index, accept)`. **`index` is not a slot** — it is a 0-based index into the client's
//! own growable pending-equip array (capacity `0xc4c290`, count `0xc4c294`, base `0xc4c298`, stride
//! `0x20`), which is also what `EQUIP_BIND_CONFIRM`'s and `AUTOEQUIP_BIND_CONFIRM`'s `arg1` carries.
//! `UIParent.lua:324-339` hangs it on `dialog.data` and the entries hand it straight back, so the
//! number is opaque on both sides — which is why benilla keeps the reference's index space here
//! instead of translating it the way the loot arm's row number is translated (1744).
//!
//! **Accept is a re-issue of the original action with a suppress flag, not a confirm packet** —
//! there is no `CMSG_CONFIRM_*` in 1.12. Cancel sends nothing at all; it only releases the item
//! locks the deferred action took (`UnlockItem 0x495420`).
//!
//! `ConfirmBindOnUse()` `0x48d770` is the use arm's answer and takes **no** argument, because that
//! arm's pending state is not the array: it is two globals the fire site stamps (the item guid at
//! `0xc4c240`, the target guid at `0xc4c1d0`), and the accept is `CGItem::Use(&target, suppress=1)`.
//! `USE_BIND_CONFIRM` carries no arguments either, and the reference ships **no** cancel binding for
//! it — declining simply drops the question.
//!
//! All three decisions are **client-local**: the client reads the cached item template's `bonding`
//! and defers its own send. No packet raises any of them (VERIFIED, wow-re
//! `system/object-layer/scratch/bind-confirm-law.md`).
//!
//! The app owns the pending records, so this module holds only the intents.
use mlua::Lua;
use super::Model;
/// One answer to a pending-equip question: the 0-based array index the event handed out, and
/// whether the player accepted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PendingEquipAnswer {
/// The index `EQUIP_BIND_CONFIRM`/`AUTOEQUIP_BIND_CONFIRM` carried out on `arg1`.
pub index: u32,
/// `true` from `EquipPendingItem` (re-issue the action), `false` from `CancelPendingEquip`
/// (release the locks, send nothing).
pub accept: bool,
}
impl super::UiScript {
/// Drain the `EquipPendingItem`/`CancelPendingEquip` answers queued since the last drain, in
/// call order. An index the app is not holding a record for is the app's to ignore — the
/// reference's `0x5e1be0` bounds-checks against the live element count and returns.
pub fn take_pending_equip_answers(&mut self) -> Vec<PendingEquipAnswer> {
std::mem::take(&mut self.model_mut().pending_equip_answers)
}
/// Drain the `ConfirmBindOnUse()` calls queued since the last drain — a count, because the verb
/// has no payload ([`super::UiScript::take_binder_confirms`]'s shape and its reason).
pub fn take_bind_on_use_confirms(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().bind_on_use_confirms)
}
/// The client's item-usable predicate `0x5ea930` for an item known by entry — one of the two
/// non-trivial conjuncts on the equip arms' deferral (an item the player *cannot* equip is
/// never asked about, because the action it would confirm is one the server would refuse).
///
/// Public because the deferral is decided app-side, where the send is: the engine holds the
/// templates and the player's requirement state, so the question has to be asked here.
/// Same conventions as every other caller ([`super::item_stats::item_usable_by_id`]): entry `0`
/// and an unanswered template are both usable.
pub fn item_usable(&self, item_id: u32) -> bool {
super::item_stats::item_usable_by_id(&self.model_ref(), item_id)
}
}
/// Register the three globals (the style [`super::binder`] registers its two).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// EquipPendingItem(index) — `StaticPopupDialogs["EQUIP_BIND"].OnAccept` and its AUTOEQUIP twin
// (`StaticPopup.lua:612-647`). Re-issues the deferred action; the app owns what that action was.
g.set(
"EquipPendingItem",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pending_equip_answers.push(PendingEquipAnswer {
index,
accept: true,
});
Ok(())
})?,
)?;
// CancelPendingEquip(index) — the same two entries' `OnCancel` AND their `OnHide`, which is not
// redundancy: `exclusive = 1` means a second bind question hides the standing dialog, and the
// reference relies on that `OnHide` to cancel the record it supersedes. A superseded pending
// equip is CANCELLED, never silently overwritten — without it the old item's locks leak.
g.set(
"CancelPendingEquip",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pending_equip_answers.push(PendingEquipAnswer {
index,
accept: false,
});
Ok(())
})?,
)?;
// ConfirmBindOnUse() — `StaticPopupDialogs["USE_BIND"].OnAccept` (`StaticPopup.lua:648-658`).
// No argument and no cancel twin: the use arm's pending state is a single client global, and
// declining just drops it.
g.set(
"ConfirmBindOnUse",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.bind_on_use_confirms += 1;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The two equip verbs share one queue and are distinguished only by their answer — the app
/// needs the call ORDER as well as the verdicts, because a supersede is a cancel of one record
/// arriving between two others.
#[test]
fn the_equip_answers_queue_in_call_order_with_their_verdicts() {
let mut s = UiScript::new().unwrap();
s.run("EquipPendingItem(0) CancelPendingEquip(1) EquipPendingItem(2)")
.unwrap();
let answers = s.take_pending_equip_answers();
assert_eq!(
answers
.iter()
.map(|a| (a.index, a.accept))
.collect::<Vec<_>>(),
vec![(0, true), (1, false), (2, true)]
);
assert!(s.take_pending_equip_answers().is_empty(), "drained");
}
/// `ConfirmBindOnUse` is a count, like `ConfirmBinder`: the verb carries no payload because the
/// pending item is the app's.
#[test]
fn confirm_bind_on_use_counts() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.take_bind_on_use_confirms(), 0);
s.run("ConfirmBindOnUse() ConfirmBindOnUse()").unwrap();
assert_eq!(s.take_bind_on_use_confirms(), 2);
assert_eq!(s.take_bind_on_use_confirms(), 0, "drained");
}
}