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:
Sam
2026-08-31 20:16:49 +07:00
co-authored by Claude Fable 5
parent 542c2ac04b
commit 8ae42cc7ab
69 changed files with 6829 additions and 1479 deletions
+65 -10
View File
@@ -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");
}
}
+193
View File
@@ -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(())
}
+39 -13
View File
@@ -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"
);
}
+59 -2
View File
@@ -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(),
}
}
+12 -3
View File
@@ -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)?;
+36 -13
View File
@@ -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,
+22
View File
@@ -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
+84 -2
View File
@@ -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`);
+35 -4
View File
@@ -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
+52 -3
View File
@@ -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]