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>
This commit is contained in:
@@ -95,6 +95,21 @@ pub(crate) struct StoredActionState {
|
||||
pub(crate) cooldown: Option<(f64, f64, bool)>,
|
||||
}
|
||||
|
||||
/// One queued `UseAction` press — the action id, and whether it carried the **self-cast
|
||||
/// modifier**.
|
||||
///
|
||||
/// A struct rather than a `(u32, bool)` because a bare bool in a tuple is exactly the argument
|
||||
/// that gets swapped silently; the neighbouring `take_action_sets` pairs two `u32`s, where the
|
||||
/// positions are self-describing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ActionUse {
|
||||
/// The 1-based Lua action id, as `UseAction` was given it.
|
||||
pub action: u32,
|
||||
/// `UseAction`'s third argument — 1.12's `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`) reach the
|
||||
/// bar through `ActionButtonUp(id, 1)`, and every other caller passes nothing.
|
||||
pub on_self: bool,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear) one action slot, keyed by Lua action id (1..120).
|
||||
pub fn set_action(&mut self, action: u32, slot: Option<ActionSlot>) {
|
||||
@@ -140,8 +155,8 @@ impl super::UiScript {
|
||||
self.model_mut().bonus_bar_offset = offset;
|
||||
}
|
||||
|
||||
/// Drain the action ids queued by `UseAction` since the last call.
|
||||
pub fn take_action_uses(&mut self) -> Vec<u32> {
|
||||
/// Drain the presses queued by `UseAction` since the last call.
|
||||
pub fn take_action_uses(&mut self) -> Vec<ActionUse> {
|
||||
std::mem::take(&mut self.model_mut().action_uses)
|
||||
}
|
||||
|
||||
@@ -257,8 +272,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UseAction(action [, checkCursor [, onSelf]]) — `onSelf` is accepted and ignored (the
|
||||
// self-cast modifier isn't modeled yet). `checkCursor` TRUTHY (numeric nonzero — the
|
||||
// UseAction(action [, checkCursor [, onSelf]]). **`onSelf` is the self-cast modifier** — the
|
||||
// third argument `ActionBar.xml`'s `ActionButtonUp(id, onSelf)` has always forwarded 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
|
||||
// reference's own convention, not Lua's: `0` reads falsy here even though Lua truthiness
|
||||
// would call it true) AND a payload is held routes to [`cursor::place_action`] instead of
|
||||
// queuing the use (decision 0216 §7's INTERIM: the reference passes 1 from a mouse click and
|
||||
@@ -270,11 +288,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"UseAction",
|
||||
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let check_cursor = rest.front().is_some_and(truthy_nonzero);
|
||||
let mut rest = rest.iter();
|
||||
let check_cursor = rest.next().is_some_and(truthy_nonzero);
|
||||
// `onSelf` reads with the SAME truthiness as `checkCursor` — numeric-nonzero, the
|
||||
// reference's convention rather than Lua's — because it arrives from the same
|
||||
// callers by the same route. `ActionButtonUp(id, 1)` is the only shipped caller
|
||||
// that passes it.
|
||||
let on_self = rest.next().is_some_and(truthy_nonzero);
|
||||
if check_cursor && model.cursor.is_some() {
|
||||
super::cursor::place_action(&mut model, action);
|
||||
} else {
|
||||
model.action_uses.push(action);
|
||||
model.action_uses.push(ActionUse { action, on_self });
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -410,6 +434,37 @@ mod tests {
|
||||
use super::ActionSlot;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// `(action, on_self)` pairs, which is what these assertions are actually about.
|
||||
fn uses(s: &mut UiScript) -> Vec<(u32, bool)> {
|
||||
s.take_action_uses()
|
||||
.into_iter()
|
||||
.map(|u| (u.action, u.on_self))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **`UseAction`'s third argument is the self-cast modifier** (1745) — 1.12's twelve
|
||||
/// `SELFACTIONBUTTON` bindings (`ALT-1`…`ALT-=`) reach the bar as `ActionButtonUp(id, 1)`,
|
||||
/// and this host dropped it on the floor until the day those bindings needed a home.
|
||||
///
|
||||
/// It reads with `checkCursor`'s truthiness, not Lua's — **numeric-nonzero** — because it
|
||||
/// arrives from the same callers by the same route. That is the assertion worth having: Lua
|
||||
/// would call `0` true, and a bar where `ActionButtonUp(id, 0)` self-cast would be worse than
|
||||
/// one where nothing did.
|
||||
#[test]
|
||||
fn use_actions_third_argument_is_the_self_cast_modifier() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run("UseAction(1)").unwrap();
|
||||
s.run("UseAction(2, 0)").unwrap();
|
||||
s.run("UseAction(3, 0, 0)").unwrap();
|
||||
s.run("UseAction(4, 0, 1)").unwrap();
|
||||
s.run("UseAction(5, nil, 1)").unwrap();
|
||||
assert_eq!(
|
||||
uses(&mut s),
|
||||
vec![(1, false), (2, false), (3, false), (4, true), (5, true)],
|
||||
"only a numeric-nonzero third argument is the modifier"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_snapshot_reads_and_use_queues() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -438,8 +493,8 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return GetBonusBarOffset()").unwrap(), 1);
|
||||
|
||||
s.run("UseAction(73)").unwrap();
|
||||
s.run("UseAction(74, 0, 1)").unwrap(); // extras accepted, ignored
|
||||
assert_eq!(s.take_action_uses(), vec![73, 74]);
|
||||
s.run("UseAction(74, 0, 1)").unwrap(); // the self-cast modifier rides out
|
||||
assert_eq!(uses(&mut s), vec![(73, false), (74, true)]);
|
||||
assert!(s.take_action_uses().is_empty());
|
||||
|
||||
s.set_action(73, None);
|
||||
@@ -480,7 +535,7 @@ mod tests {
|
||||
|
||||
// checkCursor 0 (a keybind's own convention): queues the use, cursor untouched.
|
||||
s.run("UseAction(9, 0)").unwrap();
|
||||
assert_eq!(s.take_action_uses(), vec![9]);
|
||||
assert_eq!(uses(&mut s), vec![(9, false)]);
|
||||
assert!(s.cursor_payload().is_some(), "checkCursor 0 never places");
|
||||
|
||||
// checkCursor 1 (a mouse click) with a payload held: places instead of queuing.
|
||||
@@ -491,7 +546,7 @@ mod tests {
|
||||
|
||||
// checkCursor 1 with an EMPTY cursor: the ordinary use (nothing to place).
|
||||
s.run("UseAction(10, 1)").unwrap();
|
||||
assert_eq!(s.take_action_uses(), vec![10]);
|
||||
assert_eq!(uses(&mut s), vec![(10, false)]);
|
||||
}
|
||||
|
||||
// ── `GetActionText` (wow-re `bag-language-combat-action-bindings.md` §4) ────────────────────
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The **five camera views** — `SetView` / `SaveView` / `ResetView` / `NextView` / `PrevView`,
|
||||
//! plus `FlipCameraYaw`: the outbound half of the reference's `UIUtil\Camera.cpp` Lua surface.
|
||||
//!
|
||||
//! Six globals, no state — [`super::follow`]'s shape exactly. Nothing here reads the camera: every
|
||||
//! one of these is an **engine action** over state this VM does not hold (the orbit arm, the pitch,
|
||||
//! the character's facing, the zoom ceiling), so each call queues a [`CameraViewRequest`] the app
|
||||
//! drains ([`super::UiScript::take_camera_view_requests`]) and applies to the rig
|
||||
//! (`benilla_app::player::camera_view`). The alternative — reaching into the ECS from inside the
|
||||
//! VM — is the thing decision 0068 §3 exists to forbid.
|
||||
//!
|
||||
//! **Where they come from.** The reference registers them in one table, `0x84f7a0` (`{const char*
|
||||
//! name; handler}` pairs, dumped from `WoW.exe` 5875): rows 15…20 are `SetView 0x50b5b0`,
|
||||
//! `SaveView 0x50b600`, `ResetView 0x50b640`, `NextView 0x50b680`, `PrevView 0x50b690`,
|
||||
//! `FlipCameraYaw 0x50b6a0`. Sixteen 1.12 binding commands have one of these as their whole body
|
||||
//! (`SETVIEW1`…`5`, `SAVEVIEW2`…`5`, `RESETVIEW2`…`5`, `NEXTVIEW`, `PREVVIEW`, `FLIPCAMERAYAW`).
|
||||
//!
|
||||
//! ## The argument ABI is a third shape, and it is neither of [`super::binding_abi`]'s two
|
||||
//!
|
||||
//! The four indexed entry points share one prologue, byte-identical across `0x50b5b0` /
|
||||
//! `0x50b600` / `0x50b640`:
|
||||
//!
|
||||
//! ```text
|
||||
//! 50b5b8 call 0x6f34d0 ; is-number(L, 1) — a number, or a coercible string
|
||||
//! 50b5bf je <exit> ; NOT a number -> xor eax,eax ; ret (silent, zero Lua values)
|
||||
//! 50b5c8 call 0x6f3620 ; tonumber(L, 1)
|
||||
//! 50b5cd call 0x40a2b0 ; double -> int32, truncating toward zero
|
||||
//! 50b5d4 jle <exit> ; n <= 0 -> silent
|
||||
//! 50b5d9 jg <exit> ; n > 5 -> silent
|
||||
//! 50b5e5 dec eax ; Lua 1..5 -> internal view 0..4
|
||||
//! ```
|
||||
//!
|
||||
//! So the failure edge **returns without raising** — it never reaches `luaL_error 0x6f4940`, which
|
||||
//! is the discriminator [`super::binding_abi`]'s header sets out. A macro doing `SetView(0)`,
|
||||
//! `SetView(9)`, `SetView("left")` or `SetView()` is a silent no-op in the real client, and is one
|
||||
//! here. That rules out `binding_abi::number_arg` (shape A — raises) and makes
|
||||
//! `binding_abi::coerced_number` (shape C — no guard, defaults to `0.0`) merely *accidentally*
|
||||
//! right, so the guard is written out rather than borrowed.
|
||||
//!
|
||||
//! `FlipCameraYaw` takes the same is-number guard and then the raw float — `0x6f3620` straight to
|
||||
//! `fstp [ebp-4]`, with no `0x40a2b0` — so its argument is **not** truncated to an integer.
|
||||
//!
|
||||
//! ## Two verified facts that a reader will expect to be otherwise
|
||||
//!
|
||||
//! - **The range is 1…5 for all three of Set/Save/Reset.** `SaveView(1)` and `ResetView(1)` are
|
||||
//! accepted by the engine and act on `FIRST_PERSON`; `0x50fa30` (SaveView's body) has no view-0
|
||||
//! gate at all. 1.12 ships no `SAVEVIEW1`/`RESETVIEW1` *binding*, which is a `Bindings.xml`
|
||||
//! decision, not an engine one — a macro reaches what the keybinding cannot.
|
||||
//! - **`NextView`/`PrevView` do not wrap.** `0x50faa0` is `eax = view + 1; cmp eax,5; jge <ret>`
|
||||
//! and `0x50fac0` is `test eax,eax; jle <ret>; dec eax` — each is a hard stop at its end of the
|
||||
//! range, so holding `END` walks 1→2→3→4→5 and stays there. The app enforces it (this side only
|
||||
//! queues the verb).
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// The number of camera views — the reference's default-string table `0x84f488` is five rows, and
|
||||
/// every entry point above is bounded by it.
|
||||
pub const CAMERA_VIEW_COUNT: u8 = 5;
|
||||
|
||||
/// One camera-view intent queued by the Lua surface, drained by the app
|
||||
/// ([`super::UiScript::take_camera_view_requests`]). Plain data — [`super::follow::FollowRequest`]'s
|
||||
/// twin.
|
||||
///
|
||||
/// The indices are the **internal** ones, `0..CAMERA_VIEW_COUNT`, already range-checked here
|
||||
/// exactly where the reference range-checks them (its Lua handler, `dec eax`). The app applies
|
||||
/// them; it never re-validates a number that cannot be out of range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum CameraViewRequest {
|
||||
/// `SetView(n)` — make view `n-1` the live one (distance, pitch and the saved yaw offset).
|
||||
Set(u8),
|
||||
/// `SaveView(n)` — store the live pose into view `n-1`.
|
||||
Save(u8),
|
||||
/// `ResetView(n)` — put view `n-1` back to its shipped default.
|
||||
Reset(u8),
|
||||
/// `NextView()` — the view above the current one, if there is one (no wrap).
|
||||
Next,
|
||||
/// `PrevView()` — the view below the current one, if there is one (no wrap).
|
||||
Prev,
|
||||
/// `FlipCameraYaw(degrees)` — add this many **degrees** to the camera's yaw. The binding body
|
||||
/// 1.12 ships is `FlipCameraYaw(180)`.
|
||||
FlipYaw(f32),
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Drain the camera-view intents queued since the last call — the app applies each to the
|
||||
/// camera rig.
|
||||
pub fn take_camera_view_requests(&mut self) -> Vec<CameraViewRequest> {
|
||||
std::mem::take(&mut self.model_mut().camera_view_requests)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared prologue of `SetView`/`SaveView`/`ResetView`: `is-number` → `tonumber` → truncate to
|
||||
/// `i32` → the `1 ..= 5` window → the internal index. `None` is every silent-return edge in one.
|
||||
fn view_arg(lua: &Lua, v: Value) -> Option<u8> {
|
||||
// `0x6f34d0` is-number, then `0x6f3620` tonumber, then `0x40a2b0`'s chop-toward-zero cast —
|
||||
// `lua.coerce_number` is the first two (Lua coerces a numeric string), `as i64 as i32` the
|
||||
// third (see `binding_abi::number_arg`, whose only difference from this is that it raises).
|
||||
let n = lua.coerce_number(v).ok().flatten()? as i64 as i32;
|
||||
// `test eax,eax; jle` then `cmp eax,5; jg` — both silent.
|
||||
(1..=i32::from(CAMERA_VIEW_COUNT))
|
||||
.contains(&n)
|
||||
.then(|| (n - 1) as u8)
|
||||
}
|
||||
|
||||
/// Register the six camera-view globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// SetView(n) — `0x50b5b0`. Load view n (1..5). The reference then calls `0x512e90(view,
|
||||
// cameraViewBlendStyle)`, which also writes the `cameraView` CVar so the active view survives
|
||||
// a restart; the app does both halves.
|
||||
g.set(
|
||||
"SetView",
|
||||
lua.create_function(|lua, n: Value| {
|
||||
if let Some(view) = view_arg(lua, n) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.camera_view_requests
|
||||
.push(CameraViewRequest::Set(view));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SaveView(n) — `0x50b600` → `0x50fa30`, which reads the camera's three TARGET fields
|
||||
// (`+0x198` distance, `+0x1e0` pitch, `+0x210` yaw) into the slot array and writes each to its
|
||||
// archived CVar. Accepts n = 1 (see the module header).
|
||||
g.set(
|
||||
"SaveView",
|
||||
lua.create_function(|lua, n: Value| {
|
||||
if let Some(view) = view_arg(lua, n) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.camera_view_requests
|
||||
.push(CameraViewRequest::Save(view));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ResetView(n) — `0x50b640` → `0x50fae0`, which re-parses the shipped default strings at
|
||||
// `0x84f488 + 12·view` into the slot and re-applies the view if it is the live one.
|
||||
g.set(
|
||||
"ResetView",
|
||||
lua.create_function(|lua, n: Value| {
|
||||
if let Some(view) = view_arg(lua, n) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.camera_view_requests
|
||||
.push(CameraViewRequest::Reset(view));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// NextView() / PrevView() — `0x50b680` → `0x50faa0` and `0x50b690` → `0x50fac0`. No arguments
|
||||
// at all: neither handler touches the Lua stack. Neither wraps.
|
||||
g.set(
|
||||
"NextView",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.camera_view_requests.push(CameraViewRequest::Next);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"PrevView",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.camera_view_requests.push(CameraViewRequest::Prev);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// FlipCameraYaw(degrees) — `0x50b6a0`, the whole body being
|
||||
// `cam[+0x100] += arg × 0.01745329238474369`. Its argument keeps its fraction (no `0x40a2b0`),
|
||||
// and the is-number guard's failure edge is the same silent return the four above take.
|
||||
g.set(
|
||||
"FlipCameraYaw",
|
||||
lua.create_function(|lua, degrees: Value| {
|
||||
if let Some(d) = lua.coerce_number(degrees).ok().flatten() {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.camera_view_requests
|
||||
.push(CameraViewRequest::FlipYaw(d as f32));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -472,6 +472,24 @@ fn write_inset_anchors(lua: &Lua, h: FrameHandle, rh: RegionHandle, [l, r, t, b]
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapper for an EditBox's **embedded** text FontString — the region its ctor built at
|
||||
/// `0x779bee` (`Arena::build_editbox_engine_regions`). `None` when `frame` is not a live EditBox.
|
||||
///
|
||||
/// The loader's special-`<FontString>` pass uses this instead of `CreateFontString`: RF-0028 lists
|
||||
/// `<FontString>` as the EditBox's *embedded* font string (its `bytes` attr writes the box's own
|
||||
/// `maxBytes`), so the element DECLARES the ctor's object rather than adding a region. Creating a
|
||||
/// second one would leave an orphan on the frame and push the authored `<Layers>` regions one place
|
||||
/// down the creation-ordered list `GetRegions` walks.
|
||||
pub(crate) fn editbox_text_region_wrapper(lua: &Lua, frame: &Table) -> Option<Table> {
|
||||
let h = frame_handle_of(lua, frame).ok()?;
|
||||
let rh = ensure_text_region(lua, h)?;
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.region_id(rh)
|
||||
};
|
||||
super::region::region_wrapper(lua, id).ok()
|
||||
}
|
||||
|
||||
/// The loader's special-`<FontString>` slot assignment: adopt `region` as `frame`'s text region,
|
||||
/// anchored by the current insets. The engine ASSIGNS an EditBox's font string at LoadXML — it
|
||||
/// never searches the region list (a find-first here once grabbed a `<Layers>` FontString, the
|
||||
@@ -504,20 +522,28 @@ pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHand
|
||||
Some(KindState::EditBox(eb)) => (eb.text_region, eb.multi_line),
|
||||
_ => return None,
|
||||
};
|
||||
if existing.is_some() {
|
||||
return existing;
|
||||
}
|
||||
let rh = model
|
||||
.arena
|
||||
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?;
|
||||
let mut data = RegionData {
|
||||
// Some("") from birth — the empty box still emits its Text quad for the host caret.
|
||||
text: Some(String::new()),
|
||||
..RegionData::default()
|
||||
// The ctor already built it (`Arena::build_editbox_engine_regions` — the client's own
|
||||
// `0x779bee`, the first of the five regions a CSimpleEditBox is born with). The REGION exists
|
||||
// from birth; its `RegionData` does not, because the arena has no access to it — so seed that
|
||||
// here, once, on whichever call arrives first.
|
||||
let rh = match existing {
|
||||
Some(rh) => rh,
|
||||
// A box whose ctor pass could not run (a dead handle, or a non-EditBox that slipped the
|
||||
// guard above) still gets a region rather than silently rendering nothing.
|
||||
None => model
|
||||
.arena
|
||||
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?,
|
||||
};
|
||||
apply_text_region_justify(&mut data, multi_line);
|
||||
model.region_data.insert(rh, data);
|
||||
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
|
||||
if let std::collections::hash_map::Entry::Vacant(slot) = model.region_data.entry(rh) {
|
||||
let mut data = RegionData {
|
||||
// Some("") from birth — the empty box still emits its Text quad for the host caret.
|
||||
text: Some(String::new()),
|
||||
..RegionData::default()
|
||||
};
|
||||
apply_text_region_justify(&mut data, multi_line);
|
||||
slot.insert(data);
|
||||
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
|
||||
}
|
||||
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
eb.text_region = Some(rh);
|
||||
}
|
||||
|
||||
@@ -1369,3 +1369,84 @@ fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
|
||||
"too many raises too"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A `CSimpleEditBox` is born with FIVE regions, and `GetRegions` hands them to Lua before any
|
||||
/// authored one.** wow-re `scratch/rf85-editbox-caret.md` §1: the ctor builds the text FontString
|
||||
/// (`E+0x328`, `0x779bee`), three selection-highlight `CSimpleTexture`s (`E+0x350/0x354/0x358`,
|
||||
/// loop `0x779c41`–`0x779c72`) and the caret (`E+0x368`, `0x779c86`) — in that order. `GetRegions
|
||||
/// 0x773f60` walks `[frame+0x1b8]`, one flat creation-ordered list, oldest first, no filter
|
||||
/// (`scratch/widget-list-bindings.md`), and insertion is at the TAIL. So the authored `<Layers>`
|
||||
/// regions start at index 6.
|
||||
///
|
||||
/// The report is pfUI's `skins/blizzard/friends.lua` l.379 —
|
||||
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — which skips exactly
|
||||
/// those five and takes the box's two border textures. It died on a nil `left` for as long as
|
||||
/// benilla returned only the authored regions.
|
||||
#[test]
|
||||
fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
|
||||
let s = script();
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
<EditBox name="Box">
|
||||
<Size><AbsDimension x="120" y="20"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BoxLeft" file="Interface\Left"/>
|
||||
<Texture name="BoxRight" file="Interface\Right"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<FontString inherits="ChatFontNormal"/>
|
||||
</EditBox>
|
||||
</Ui>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// Five engine regions, then the two authored textures — and NOT a sixth from the embedded
|
||||
// `<FontString>`, which declares the ctor's object rather than adding one (RF-0028).
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return Box:GetNumRegions()").unwrap(),
|
||||
7,
|
||||
"5 ctor regions + 2 authored textures, and the <FontString> adds none"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', Box:GetRegions())")
|
||||
.unwrap(),
|
||||
7,
|
||||
"GetNumRegions is exactly the length GetRegions enumerates"
|
||||
);
|
||||
|
||||
// pfUI's own read, verbatim in shape: skip five, take the authored pair.
|
||||
let (left, right) = s
|
||||
.eval::<(String, String)>(
|
||||
"local _,_,_,_,_,l,r = Box:GetRegions() return l:GetTexture(), r:GetTexture()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
left, r"Interface\Left",
|
||||
"region 6 is the first authored one"
|
||||
);
|
||||
assert_eq!(right, r"Interface\Right", "region 7 is the second");
|
||||
|
||||
// The first is the text FontString the box actually types into.
|
||||
s.run(r#"Box:SetText("typed")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("local t = Box:GetRegions() return t:GetText()")
|
||||
.unwrap(),
|
||||
"typed",
|
||||
"region 1 is the ctor's embedded text FontString"
|
||||
);
|
||||
// 2..5 are the three selection quads and the caret: real regions, carrying no art of their own
|
||||
// (benilla paints both host-side — the gap named on EditBoxState).
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"local a,b,c,d,e = Box:GetRegions() \
|
||||
return b:GetTexture() == nil and c:GetTexture() == nil \
|
||||
and d:GetTexture() == nil and e:GetTexture() == nil"
|
||||
)
|
||||
.unwrap(),
|
||||
"the selection trio and the caret are blank quads"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,8 +373,19 @@ impl KeybindState {
|
||||
fn register_addon(&mut self, addon: &str, bindings: &[AddonBinding]) {
|
||||
let mut section = addon.to_string();
|
||||
for b in bindings {
|
||||
// Before the skip: a duplicate name still *closes* a section the same way, because the
|
||||
// header is a property of the file's order, not of the row that survived it.
|
||||
// **`platform` skips the whole NODE, header and all** — `0x4b70c3`-`0x4b70e5` bails
|
||||
// out of the element before anything on it is read. It matters here and nowhere else:
|
||||
// `header="ITUNES_REMOTE"` rides on `ITUNES_PLAYPAUSE`, the first of the five
|
||||
// `platform="mac"` rows, so on a build that is not the Mac one that category does not
|
||||
// merely come up empty — it never exists. Keeping the header of a skipped row would
|
||||
// leave its section open over whatever followed.
|
||||
if b.platform.as_deref().is_some_and(|p| p != THIS_PLATFORM) {
|
||||
continue;
|
||||
}
|
||||
// Before the duplicate skip below: a duplicate name still *closes* a section the same
|
||||
// way, because the header is a property of the file's order, not of the row that
|
||||
// survived it. (Not the platform case above — a duplicate is a node the loader read
|
||||
// and rejected; a foreign-platform node it never read at all.)
|
||||
if let Some(h) = &b.header {
|
||||
section = format!("BINDING_HEADER_{h}");
|
||||
}
|
||||
@@ -411,6 +422,17 @@ impl KeybindState {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `platform="…"` has to equal for a binding to register here — the reference's own two
|
||||
/// spellings, read out of both binaries: the Windows build compares against `"windows"`
|
||||
/// (`0x846f98`, tested at `0x4b70c3`-`0x4b70e5`) and the Mac slice against `"mac"` (`0x4e28f8`).
|
||||
/// Every other OS benilla runs on takes the `"windows"` name rather than inventing a third — the
|
||||
/// only thing this attribute has ever distinguished is a 2006 Carbon app from everything else.
|
||||
const THIS_PLATFORM: &str = if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
|
||||
impl super::UiScript {
|
||||
/// Register the host-implemented commands (boot-time, order = the 1.12 `Bindings.xml`
|
||||
/// order). Idempotent per name; live keys seed from the defaults.
|
||||
@@ -984,6 +1006,40 @@ mod tests {
|
||||
assert!(bodies[0].body.contains(r#"keystate == "down""#));
|
||||
}
|
||||
|
||||
/// **The file's own OS gate.** `platform="mac"` is 1.12's one OS-specific device in
|
||||
/// `Bindings.xml` — five `ITUNES_REMOTE` rows in the Mac build and nowhere else — and a row
|
||||
/// for the other build is not registered at all. Not hidden, not registered-and-inert:
|
||||
/// registering it would list a command in the Key Bindings window whose body calls functions
|
||||
/// this build does not have, and let the player spend a key on it.
|
||||
#[test]
|
||||
fn a_binding_for_another_platform_does_not_register() {
|
||||
let mut s = script();
|
||||
let parsed = crate::bindings_xml::parse(
|
||||
r#"<Bindings>
|
||||
<Binding name="PROBEHERE" platform="THIS">Here();</Binding>
|
||||
<Binding name="PROBEELSEWHERE" platform="elsewhere">Elsewhere();</Binding>
|
||||
<Binding name="PROBEANYWHERE">Anywhere();</Binding>
|
||||
</Bindings>"#
|
||||
.replace("THIS", super::THIS_PLATFORM)
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
s.register_addon_bindings("Probe", &parsed);
|
||||
|
||||
let names: Vec<String> = s.keybind_snapshot().into_iter().map(|(n, _)| n).collect();
|
||||
assert!(names.iter().any(|n| n == "PROBEHERE"), "{names:?}");
|
||||
assert!(names.iter().any(|n| n == "PROBEANYWHERE"), "{names:?}");
|
||||
assert!(
|
||||
!names.iter().any(|n| n == "PROBEELSEWHERE"),
|
||||
"a foreign-platform row registered: {names:?}"
|
||||
);
|
||||
// …and it is not merely hidden from the window either: nothing can bind it.
|
||||
assert!(!s
|
||||
.addon_binding_bodies()
|
||||
.iter()
|
||||
.any(|b| b.name == "PROBEELSEWHERE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_seeding_feeds_load_and_the_capture_arm_reads_back() {
|
||||
let mut s = script();
|
||||
@@ -1022,6 +1078,7 @@ mod addon_persistence_tests {
|
||||
header: None,
|
||||
run_on_up: false,
|
||||
hidden: false,
|
||||
platform: None,
|
||||
body: "AddonBindingRan = true".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,12 @@ mod auction;
|
||||
mod aura;
|
||||
mod backdrop;
|
||||
mod bank;
|
||||
mod bind_confirm;
|
||||
mod binder;
|
||||
mod binding_abi;
|
||||
// The five camera views + FlipCameraYaw — the reference's `UIUtil\Camera.cpp` Lua surface.
|
||||
mod button;
|
||||
mod camera_view;
|
||||
mod channel;
|
||||
mod char_stats;
|
||||
mod chat_send;
|
||||
@@ -59,7 +62,7 @@ mod cursor;
|
||||
mod death;
|
||||
pub mod diagnostics;
|
||||
mod editbox;
|
||||
pub(crate) use editbox::adopt_text_region;
|
||||
pub(crate) use editbox::{adopt_text_region, editbox_text_region_wrapper};
|
||||
pub(crate) mod addon;
|
||||
pub mod addon_gate;
|
||||
mod client;
|
||||
@@ -143,7 +146,7 @@ mod worldmap;
|
||||
mod worldstate;
|
||||
mod worn_display;
|
||||
|
||||
pub use action::{ActionSlot, ActionState};
|
||||
pub use action::{ActionSlot, ActionState, ActionUse};
|
||||
pub use addon::AddOnInfo;
|
||||
pub use addon_message::{AddonDistribution, AddonSend};
|
||||
pub use auction::{
|
||||
@@ -153,6 +156,8 @@ pub use auction::{
|
||||
pub use aura::{AuraState, TrackingState};
|
||||
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
|
||||
pub use bank::BankState;
|
||||
pub use bind_confirm::PendingEquipAnswer;
|
||||
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
|
||||
pub use char_stats::{
|
||||
weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,
|
||||
SKILL_DEFENSE, SKILL_UNARMED,
|
||||
@@ -233,7 +238,9 @@ pub use types::{
|
||||
ScriptValue, TexCoords,
|
||||
};
|
||||
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
|
||||
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
|
||||
pub use unit::{
|
||||
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
|
||||
};
|
||||
pub use weapon_enchant::WeaponEnchant;
|
||||
pub use worldmap::{
|
||||
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
|
||||
@@ -561,9 +568,11 @@ impl UiScript {
|
||||
petition::install(&lua)?;
|
||||
binder::install(&lua)?;
|
||||
summon::install(&lua)?;
|
||||
bind_confirm::install(&lua)?;
|
||||
gm_ticket::install(&lua)?;
|
||||
duel::install(&lua)?;
|
||||
follow::install(&lua)?;
|
||||
camera_view::install(&lua)?;
|
||||
session::install(&lua)?;
|
||||
pvp::install(&lua)?;
|
||||
worn_display::install(&lua)?;
|
||||
|
||||
@@ -4,12 +4,12 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
|
||||
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
|
||||
|
||||
use super::{
|
||||
auction, backdrop, bank, char_stats, chat_window, colorselect, container, craft, cursor, death,
|
||||
duel, follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant,
|
||||
party, petition, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider,
|
||||
social, spellbook, stable, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot,
|
||||
AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest,
|
||||
UnitState,
|
||||
auction, backdrop, bank, bind_confirm, camera_view, char_stats, chat_window, colorselect,
|
||||
container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot,
|
||||
loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session,
|
||||
simplehtml, skills, slider, social, spellbook, stable, taxi, trade, tradeskill, trainer,
|
||||
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
|
||||
RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -402,11 +402,17 @@ pub(crate) struct Model {
|
||||
/// [`super::aura::TrackingState`]), pushed by the app's aura feed beside `auras`. `None` =
|
||||
/// no tracking active.
|
||||
pub(crate) tracking: Option<super::aura::TrackingState>,
|
||||
/// Unit tokens `TargetUnit` queued since the app's last [`UiScript::take_target_requests`] drain
|
||||
/// — the outbound twin of `units` above (the reference's `TargetUnit` Lua shim → SetSelection).
|
||||
/// The app resolves each token to a streamed entity and commits the selection; a token it can't
|
||||
/// resolve is a no-op, as the real client no-ops `TargetUnit` on a nonexistent unit ([`unit`]).
|
||||
pub(crate) target_requests: Vec<String>,
|
||||
/// Selection asks queued since the app's last [`UiScript::take_selection_requests`] drain — the
|
||||
/// outbound twin of `units` above, carrying `TargetUnit` / `AssistUnit` / `TargetLastEnemy` in
|
||||
/// call order because the reference reaches selection through one helper for all three
|
||||
/// ([`super::SelectionRequest`]). The app resolves each to a streamed entity and commits the
|
||||
/// selection; one it can't resolve is a no-op, as the real client no-ops `TargetUnit` on a
|
||||
/// nonexistent unit ([`unit`]).
|
||||
pub(crate) selection_requests: Vec<super::SelectionRequest>,
|
||||
/// `TargetNearestFriend([reverse])` presses queued since the app's last
|
||||
/// [`UiScript::take_target_nearest_friend_requests`] drain — one entry per call, `true` for the
|
||||
/// reverse (backward) cycle. The app runs the friendly half of the TAB scan ([`unit`]).
|
||||
pub(crate) target_nearest_friend_requests: Vec<bool>,
|
||||
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
|
||||
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
|
||||
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
|
||||
@@ -509,6 +515,13 @@ pub(crate) struct Model {
|
||||
/// queue's twin, snapshot-less for the same reason: the only thing the UI reads back is the
|
||||
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` pair the app fires, which carries its own argument.
|
||||
pub(crate) follow_requests: Vec<follow::FollowRequest>,
|
||||
/// Camera-view intents (`SetView`/`SaveView`/`ResetView`/`NextView`/`PrevView`/
|
||||
/// `FlipCameraYaw`) queued since the app's last
|
||||
/// [`super::UiScript::take_camera_view_requests`] drain — the outbound seam
|
||||
/// ([`camera_view`]). Snapshot-less like the follow queue: the five views live on the app's
|
||||
/// camera rig, and they reach Lua the way the reference's do — as the
|
||||
/// `cameraView` / `camera{Distance,Pitch,Yaw}{,A..D}` CVars — not as a second copy here.
|
||||
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
|
||||
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
|
||||
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
|
||||
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
|
||||
@@ -616,7 +629,7 @@ pub(crate) struct Model {
|
||||
/// reads it ([`action`]). Keyed like `actions`; an absent action reads cold/nil.
|
||||
pub(crate) action_states: HashMap<u32, super::action::StoredActionState>,
|
||||
pub(crate) bonus_bar_offset: u8,
|
||||
pub(crate) action_uses: Vec<u32>,
|
||||
pub(crate) action_uses: Vec<super::action::ActionUse>,
|
||||
/// `(lua action id, packed)` pairs queued by `PickupAction`/`PlaceAction` (decision 0216 §7,
|
||||
/// slice 4) — one entry per local slot mutation, `packed == 0` clearing the slot. Drained by
|
||||
/// the app into `CMSG_SET_ACTION_BUTTON`, one send per entry (client-authoritative, per-change
|
||||
@@ -981,6 +994,12 @@ pub(crate) struct Model {
|
||||
/// drain.
|
||||
pub(crate) craft_close: bool,
|
||||
|
||||
/// The `EquipPendingItem`/`CancelPendingEquip` answers the app drains, in call order, and the
|
||||
/// `ConfirmBindOnUse()` count — the non-loot soulbind confirmations' whole Lua side
|
||||
/// ([`bind_confirm`], decision 1750). The pending records themselves are the app's.
|
||||
pub(crate) pending_equip_answers: Vec<bind_confirm::PendingEquipAnswer>,
|
||||
pub(crate) bind_on_use_confirms: u32,
|
||||
|
||||
/// The open loot's row snapshot the app pushes (`None` = no loot open), the row-pick intents it
|
||||
/// drains, and whether `CloseLoot` was called — the loot seam ([`loot`]).
|
||||
///
|
||||
@@ -1517,7 +1536,8 @@ impl Model {
|
||||
auras: HashMap::new(),
|
||||
cancel_aura_requests: Vec::new(),
|
||||
tracking: None,
|
||||
target_requests: Vec::new(),
|
||||
selection_requests: Vec::new(),
|
||||
target_nearest_friend_requests: Vec::new(),
|
||||
target_by_name_requests: Vec::new(),
|
||||
drop_item_on_unit: Vec::new(),
|
||||
target_clear: false,
|
||||
@@ -1543,6 +1563,7 @@ impl Model {
|
||||
default_language: None,
|
||||
duel_requests: Vec::new(),
|
||||
follow_requests: Vec::new(),
|
||||
camera_view_requests: Vec::new(),
|
||||
session_requests: Vec::new(),
|
||||
pvp_toggles: 0,
|
||||
honor: None,
|
||||
@@ -1658,6 +1679,8 @@ impl Model {
|
||||
craft_selection: 0,
|
||||
craft_close: false,
|
||||
loot: None,
|
||||
pending_equip_answers: Vec::new(),
|
||||
bind_on_use_confirms: 0,
|
||||
loot_picks: Vec::new(),
|
||||
loot_confirms: Vec::new(),
|
||||
loot_close: false,
|
||||
|
||||
@@ -8,6 +8,28 @@ use crate::widget::FrameHandle;
|
||||
use super::{editbox, event, tooltip, ScriptValue, UiScript};
|
||||
|
||||
impl UiScript {
|
||||
/// Queue an event to fire at the **start of the next tick**, into the same
|
||||
/// [`super::Model::pending_events`] list the engine's own bindings use — so it lands *after*
|
||||
/// everything queued earlier in the frame and before that tick's `OnUpdate` pass.
|
||||
///
|
||||
/// **Ordering, not laziness, is the whole reason this is public** (decision 1750). A benilla
|
||||
/// drain runs a step behind the input pass that fed it, so an event it fires *immediately*
|
||||
/// arrives BEFORE events the same input already queued — the reverse of the reference, where
|
||||
/// the deferral happens inside the very call that queued them. That inversion is not
|
||||
/// hypothetical: the soulbind confirm raised by placing an item into a worn slot was cancelled
|
||||
/// one tick later by that same place's own `CURSOR_UPDATE`, whose `StaticPopup_Hide` is
|
||||
/// `UIParent.lua:356-360` transcribed correctly. Queueing restores the reference's relative
|
||||
/// order — cursor-change first, question second — while a *later* cursor change still retires
|
||||
/// the question, which is exactly what that arm is for.
|
||||
///
|
||||
/// Use [`Self::fire_event`] for anything whose ordering against in-flight queued events does
|
||||
/// not matter, which is nearly everything.
|
||||
pub fn queue_event(&mut self, event: &str, args: Vec<ScriptValue>) {
|
||||
self.model_mut()
|
||||
.pending_events
|
||||
.push((event.to_string(), args));
|
||||
}
|
||||
|
||||
/// Fire an event to every frame registered for it, invoking their `OnEvent` handlers (RF-0025):
|
||||
/// both the legacy `this`/`event`/`arg1..argN` globals *and* the modern `(self, event, ...)`
|
||||
/// arguments. Events reach registered frames regardless of visibility, **in registration
|
||||
|
||||
@@ -7,7 +7,7 @@ use mlua::{Lua, Value};
|
||||
use super::super::Model;
|
||||
use super::{
|
||||
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
|
||||
power_token, with_unit,
|
||||
power_token, with_unit, SelectionRequest,
|
||||
};
|
||||
|
||||
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
|
||||
@@ -889,12 +889,94 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
if let Some(token) = token {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.target_requests.push(token);
|
||||
model.selection_requests.push(SelectionRequest::Unit(token));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// AssistUnit(unit) — select the *token's own* target (`0x489b80`; wow-re
|
||||
// `object-layer/scratch/targeting-by-name.md` PART C, §5-cross-checked). The ASSISTTARGET
|
||||
// binding's body is `AssistUnit("target")`, and `/assist`'s bare form is the same call.
|
||||
//
|
||||
// The shared assist tail (`0x489bb2`–`0x489c07`, byte-identical to `AssistByName`'s) is three
|
||||
// steps and no more: read `UNIT_FIELD_TARGET` off the basis (`[[obj+0x110]+0x28]`), bail
|
||||
// **silently** if it is zero, then hand the guid to the select-if-resolves helper `0x489a40`.
|
||||
// Four things it deliberately is not:
|
||||
//
|
||||
// * **not gated by `CanAssist`** — a whole-binary census of the 25 `call 0x6066f0` sites puts
|
||||
// none of them on this path (VERIFIED negative), so `AssistUnit("target")` on a hostile
|
||||
// creature assists it, and that is the common case in play.
|
||||
// * **not players-only** — unlike `AssistByName`'s typemask `0x10`, the token resolver takes
|
||||
// whatever the token names.
|
||||
// * **not a deselect on failure** — an unresolvable token, a basis with no target, and a
|
||||
// target that is not streamed all leave the current selection exactly where it was
|
||||
// (`0x489a40`'s arm 3 is a bare `ret`).
|
||||
// * **not an attack** — the tail's swing leg is gated on the `assistAttack` CVar, whose
|
||||
// registered default is `"0"` (VERIFIED at the registration bytes `0x48fc50`). Stock assist
|
||||
// selects and does not swing; the leg becomes reachable only if benilla grows the CVar.
|
||||
//
|
||||
// A nil/absent unit is ignored here rather than queued, like `TargetUnit`'s: the reference
|
||||
// emits game-message `0xb8` for the token it cannot parse, and that id→string table is
|
||||
// runtime-populated BSS wow-re could not statically recover — the same known deviation
|
||||
// `TargetByName` already carries, and silence is better than an invented line.
|
||||
g.set(
|
||||
"AssistUnit",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
if let Some(token) = token {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.selection_requests
|
||||
.push(SelectionRequest::Assist(token));
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TargetLastEnemy() — re-select the last **attackable** unit that was targeted (`0x489b45`
|
||||
// reads the pair `[0xb4e2e8]/[0xb4e2ec]`, which `SetSelection 0x493540` stamps at `0x49377d`
|
||||
// alongside the plain last-target pair `TargetLastTarget` reads). The TARGETLASTHOSTILE
|
||||
// binding (default `G`) is its only shipped caller.
|
||||
//
|
||||
// Takes no argument and routes through the same select-if-resolves helper, so a remembered
|
||||
// guid whose unit has since streamed out is a silent no-op rather than a deselect. The app
|
||||
// owns the memory itself (`crate::target::scan`'s `LastEnemy`) — the VM holds no guids.
|
||||
g.set(
|
||||
"TargetLastEnemy",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.selection_requests.push(SelectionRequest::LastEnemy);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TargetNearestFriend([reverse]) — the friendly half of the TAB scan. `0x489aa0` is
|
||||
// byte-for-byte `TargetNearestEnemy`'s shim with one changed immediate: both fetch the
|
||||
// optional Lua arg #1 as the reverse flag (`0x6f1c10`, default 0) and call the one cycler
|
||||
// `0x493f60(ecx = reverse, edx = mode)` — `edx = 1` for enemy, **`2` for friend** (3 and 4 are
|
||||
// the party and raid siblings). Everything downstream is the same code; only the
|
||||
// per-candidate filter `0x493e40` forks on the mode, and mode 2's arm (`0x493eca`) is
|
||||
// `CanAssist(player, cand)` then `UNIT_FIELD_HEALTH > 0`.
|
||||
//
|
||||
// TARGETNEARESTFRIEND (`CTRL-TAB`) calls it bare; TARGETPREVIOUSFRIEND (`CTRL-SHIFT-TAB`)
|
||||
// calls `TargetNearestFriend(1)` — 1.12's own `Bindings.xml` comments the argument
|
||||
// `-- 1 (or "true") means reverse!`, so the truthiness test below takes a number OR a boolean.
|
||||
// A numeric 0 is forward, matching `0x6f1c10`'s "absent == 0" reading.
|
||||
g.set(
|
||||
"TargetNearestFriend",
|
||||
lua.create_function(|lua, reverse: Option<Value>| {
|
||||
let reverse = match reverse {
|
||||
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
|
||||
Some(Value::Integer(n)) => n != 0,
|
||||
Some(Value::Number(n)) => n != 0.0,
|
||||
Some(_) => true,
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.target_nearest_friend_requests.push(reverse);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TargetByName(name [, exactMatch]) — select by NAME through the shared resolver `0x493aa0`
|
||||
// (`0x489d60`; wow-re `object-layer/scratch/targeting-by-name.md`, a §5 trio + two pairs).
|
||||
// The app already owns that resolver for `/target` (decision 0886, `crate::target::by_name`);
|
||||
|
||||
@@ -24,6 +24,27 @@ use mlua::Lua;
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// One queued **selection ask** from Lua, in call order — the app resolves it to a guid and
|
||||
/// commits it through the one SetSelection tail.
|
||||
///
|
||||
/// The three verbs share one queue because the reference shares one *function*: `TargetUnit`
|
||||
/// (`0x4899d0`), `AssistUnit` (`0x489b80`), `TargetLastEnemy` and `TargetLastTarget` all reach
|
||||
/// selection through the "select if it resolves" helper `0x489a40`, whose three arms are the same
|
||||
/// for every caller — resolve the guid and commit, else fall back to the group roster, else a
|
||||
/// **silent return** (wow-re `object-layer/scratch/selection-attack-seam.md` §1). Splitting them
|
||||
/// into a queue each would also lose their relative order, which a macro can observe
|
||||
/// (`TargetUnit("party1"); AssistUnit("target")` is not the same as the reverse).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SelectionRequest {
|
||||
/// `TargetUnit(unit)` — select the unit the token names.
|
||||
Unit(String),
|
||||
/// `AssistUnit(unit)` — select the token's own `UNIT_FIELD_TARGET`; a basis with no target is
|
||||
/// a silent no-op (the reference's shared assist tail bails before any send).
|
||||
Assist(String),
|
||||
/// `TargetLastEnemy()` — select the last *attackable* unit that was committed.
|
||||
LastEnemy,
|
||||
}
|
||||
|
||||
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
|
||||
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
|
||||
/// draws between the app's net/ECS feed and the Lua API.
|
||||
@@ -450,10 +471,20 @@ impl super::UiScript {
|
||||
model.combo_target = target;
|
||||
}
|
||||
|
||||
/// Drain the unit tokens `TargetUnit` queued since the last call — the app resolves each to a
|
||||
/// streamed entity and commits the selection (the reference's `TargetUnit` → SetSelection path).
|
||||
pub fn take_target_requests(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().target_requests)
|
||||
/// Drain the selection asks queued since the last call — the app resolves each to a guid and
|
||||
/// commits it through the one SetSelection tail. See [`SelectionRequest`] for why the three
|
||||
/// verbs share one ordered queue.
|
||||
pub fn take_selection_requests(&mut self) -> Vec<SelectionRequest> {
|
||||
std::mem::take(&mut self.model_mut().selection_requests)
|
||||
}
|
||||
|
||||
/// Drain the `TargetNearestFriend([reverse])` presses queued since the last call — one `bool`
|
||||
/// per call, the cycle direction (`false` forward, `true` backward). Its own queue rather than
|
||||
/// a [`SelectionRequest`] variant because it names no unit: the reference runs it through the
|
||||
/// TAB cycler `0x493f60(reverse, mode 2)` and straight into `SetSelection`, never through the
|
||||
/// select-if-resolves helper the other three share.
|
||||
pub fn take_target_nearest_friend_requests(&mut self) -> Vec<bool> {
|
||||
std::mem::take(&mut self.model_mut().target_nearest_friend_requests)
|
||||
}
|
||||
|
||||
/// Drain the `(name, exactMatch)` pairs `TargetByName` queued since the last call — the app
|
||||
|
||||
@@ -276,16 +276,65 @@ fn corpse_can_attack_and_green_range_bindings() {
|
||||
|
||||
#[test]
|
||||
fn target_unit_queues_the_token_for_the_app_to_resolve() {
|
||||
use crate::script::SelectionRequest;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// Nothing queued until a call lands.
|
||||
assert!(s.take_target_requests().is_empty());
|
||||
assert!(s.take_selection_requests().is_empty());
|
||||
// Each call queues its raw token, in order; a nil token is ignored.
|
||||
s.eval::<()>(r#"TargetUnit("player")"#).unwrap();
|
||||
s.eval::<()>(r#"TargetUnit("target")"#).unwrap();
|
||||
s.eval::<()>(r#"TargetUnit(nil)"#).unwrap();
|
||||
assert_eq!(s.take_target_requests(), vec!["player", "target"]);
|
||||
assert_eq!(
|
||||
s.take_selection_requests(),
|
||||
vec![
|
||||
SelectionRequest::Unit("player".into()),
|
||||
SelectionRequest::Unit("target".into()),
|
||||
]
|
||||
);
|
||||
// The drain is a take — a second read is empty.
|
||||
assert!(s.take_target_requests().is_empty());
|
||||
assert!(s.take_selection_requests().is_empty());
|
||||
}
|
||||
|
||||
/// The three verbs share ONE queue, and the queue keeps their call order — which is the whole
|
||||
/// reason it is one queue (`0x489a40` is one function for all of them, and a macro can observe
|
||||
/// the order). A nil `AssistUnit` argument is dropped like `TargetUnit`'s, and
|
||||
/// `TargetLastEnemy()` names no unit at all.
|
||||
#[test]
|
||||
fn the_selection_queue_carries_all_three_verbs_in_call_order() {
|
||||
use crate::script::SelectionRequest;
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.eval::<()>(r#"TargetUnit("party1")"#).unwrap();
|
||||
s.eval::<()>(r#"AssistUnit("target")"#).unwrap();
|
||||
s.eval::<()>(r#"AssistUnit(nil)"#).unwrap();
|
||||
s.eval::<()>("TargetLastEnemy()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_selection_requests(),
|
||||
vec![
|
||||
SelectionRequest::Unit("party1".into()),
|
||||
SelectionRequest::Assist("target".into()),
|
||||
SelectionRequest::LastEnemy,
|
||||
]
|
||||
);
|
||||
assert!(s.take_selection_requests().is_empty());
|
||||
}
|
||||
|
||||
/// `TargetNearestFriend`'s reverse flag, read exactly as `0x6f1c10(idx 1, default 0)` does:
|
||||
/// absent or nil is forward, and 1.12's own `Bindings.xml` note — *"1 (or \"true\") means
|
||||
/// reverse!"* — is why a boolean and a number both count. A numeric `0` is forward.
|
||||
#[test]
|
||||
fn target_nearest_friend_queues_its_reverse_flag() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert!(s.take_target_nearest_friend_requests().is_empty());
|
||||
s.eval::<()>("TargetNearestFriend()").unwrap();
|
||||
s.eval::<()>("TargetNearestFriend(1)").unwrap();
|
||||
s.eval::<()>("TargetNearestFriend(true)").unwrap();
|
||||
s.eval::<()>("TargetNearestFriend(0)").unwrap();
|
||||
s.eval::<()>("TargetNearestFriend(nil)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_target_nearest_friend_requests(),
|
||||
vec![false, true, true, false, false]
|
||||
);
|
||||
assert!(s.take_target_nearest_friend_requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user