every dialog is the reference's own, and the trainer loads on demand

- every dialog is the reference's own: `StaticPopup.xml` + `StaticPopup.lua` load off the player's chain, and 0308's engine with its 37 dialogs leaves `UiPanels.xml`; all 76 of the reference's dialogs are the stock table's, 28 of which no file of ours had ever registered (the resurrect family among them); the nine hidden listener frames that existed only because benilla once had no `UIParent_OnEvent` come home to it verbatim from UIParent.lua, and the seven files that were nothing but a dialog and its driver are deleted; what the swap surfaced: the reference capitalises the countdown's unit word ("5 Minutes until release") where ours had lowercased it, and ESC in the typed delete confirm's box is swallowed — the entry names no escape handler; arms for events this engine never fires (PLAYER_SKINNED, TRADE_REQUEST) wait on their feeds rather than being armed blind (1960)
- the social window and the unit menus are the reference's own files: `FriendsFrame.xml` loads off the chain and our transcription is deleted — its twelve dialogs re-homed, `FRIENDLIST_SHOW` fired as the answer to an explicit `ShowFriends()`, and `Get`/`SetLookingForGroup` a client-side flag until their bodies are carved; `UnitPopup.xml` loads off the chain, our row catalogue and its driver frame go, and nine verbs are built for it — `HasFullControl` off the player-control flag the reference reads at `0x51a158`, `UnitPlayerOrPetInParty`/`InRaid` over a new unit owner, and the six pet one-shots, each pressing the bar's own reaction or command slot word through the same latch and `CMSG_PET_ACTION`, confirmed by wow-re's carve; the Guild Leave/Promote confirms 1958 believed nothing registered were our social file's all along, and the duplicate went with it (1958, 1959)
- the trainer loads on demand: our `TrainerFrame.xml` is deleted and no row replaces it — `Blizzard_TrainerUI` loads off the player's own chain through the reference's `ClassTrainerFrame_LoadUI`, and the LoadOnDemand `Blizzard_*` addons are registry rows read off the chain; the timing IS the mechanism: the list builder writes the state-filter mask to 3 on every `SMSG_TRAINER_LIST` (`0x4d75d9`) and the stock addon pushes its saved filters exactly once, on its own ADDON_LOADED — which fires on the session's first trainer, after that packet, so the saved filters win that open and an eager load would never have applied them; the loader holds an anchor to a sibling not built yet and applies it once the subtree exists; and `UNIT_FLAGS` fires only on a change between two snapshots — wow-re's trio found the per-field watch bridge runs no notify pass on the create leg, correcting 1953 (1957)
- the additive window, built and withdrawn the same hour: under `BLEND_ADD` every additive particle draw commutes with every other, so same-key additive draws can be regrouped exactly — built in the effect lane's merge walk and measured on the crowd rig (Stormwind, forty partybots): the adjacency merge already folds about half the effect items and the regroup found two more folds in a frame of ninety-three, so the pre-regroup walk is what runs; what stays: an `fx=` items/merged census on the FPS probe line, a `DRESS_PART` line per drawn part of the self body on the dress census, and the regression the A/B uncovered: 1948's stock chat migration dropped the drain's plain-line sender, so every `WOW_PROBE_CHAT` rig — the crowd rig's forty `.partybot add`s among them — had gone silent on the server; a probe's slash-less line reaches the wire as SAY again (1955)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:57:24 +07:00
co-authored by Claude Fable 5.1
parent a43e2c9a88
commit 404708859a
116 changed files with 2215 additions and 8202 deletions
+46 -10
View File
@@ -43,6 +43,14 @@ use mlua::{Lua, MultiValue, Value};
use super::addon_gate::{can_load, GateRow, Verdict};
use super::Model;
/// A reader for a chain-sourced addon's files, by chain-internal path — what the host seats
/// through [`super::UiScript::set_addon_chain_reader`] (1957).
pub type AddonChainReader = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// [`AddonChainReader`] as the model holds it: shared, so a load can borrow it without holding
/// the model.
pub(crate) type SharedChainReader = std::rc::Rc<dyn Fn(&str) -> Option<Vec<u8>>>;
/// One addon, as the AddOn API sees it — the host fills this at discovery
/// ([`super::UiScript::register_addons`]).
#[derive(Clone, Debug, Default)]
@@ -82,6 +90,12 @@ pub struct AddOnInfo {
/// `## Interface` as the client parses it (`Toc::interface_version` — the leading integer,
/// `0` when the line is absent). What the version gate compares (decision 1292).
pub interface: u32,
/// The addon's files sit in the player's patch chain, not the AddOns folder — Blizzard's own
/// LoadOnDemand addons (`Blizzard_TrainerUI` and its eleven siblings), which the reference
/// loads through the same `LoadAddOn` path as a player's (1957). Read through the host's
/// chain reader ([`super::UiScript::set_addon_chain_reader`]) at
/// `Interface/AddOns/<Name>/<file>`; no folder root is needed for them.
pub chain: bool,
}
/// Resolve a Lua index-or-name argument to a position in the registry.
@@ -406,7 +420,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
///
/// `Err` carries the reference's own reason token.
fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
let (name, root, files, deps, already) = {
let (name, root, files, deps, already, chain, chain_reader) = {
let model = lua.app_data_ref::<Model>().expect("model");
let a = &model.addons[i];
(
@@ -415,6 +429,8 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
a.files.clone(),
a.dependencies.clone(),
a.loaded,
a.chain,
model.addons_chain_reader.clone(),
)
};
if already {
@@ -430,8 +446,19 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
return Err(refused.token().expect("a refusal always carries a token"));
}
}
let Some(root) = root else {
return Err("MISSING".into()); // no addon root: a hermetic capture has nothing to load
// Where this addon's bytes come from: the AddOns folder for a player's addon, the patch chain
// for Blizzard's own LoadOnDemand addons (`AddOnInfo::chain`). Either absent is `MISSING` —
// a hermetic capture has no folder, a bare VM has no chain.
let read: Reader = if chain {
let Some(reader) = chain_reader else {
return Err("MISSING".into());
};
Box::new(move |req: &str| reader(&format!("Interface/AddOns/{req}")))
} else {
let Some(root) = root else {
return Err("MISSING".into());
};
Box::new(move |req: &str| read_under(&root, req))
};
// The gate said yes, so a dependency failing HERE is a load-time failure (its files errored,
@@ -464,9 +491,9 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
}
}
run_files(lua, &name, &root, &files);
run_files(lua, &name, &read, &files);
// `Bindings.xml` (1188 phase 4) attaches here, between the files and the saved variables.
load_bindings(lua, &name, &root);
load_bindings(lua, &name, &read);
load_saved_variables(lua, i);
{
@@ -489,9 +516,9 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
/// A missing file is the normal case and silent — most addons declare no bindings. `Bindings.xml`
/// is the reference's own spelling and the only one probed; the sandbox is [`read_under`]'s, so
/// this reads exactly what the addon's other files may read and nothing else.
fn load_bindings(lua: &Lua, name: &str, root: &std::path::Path) {
fn load_bindings(lua: &Lua, name: &str, read: &Reader) {
let path = crate::loader::join_ref(name, "Bindings.xml");
let Some(bytes) = read_under(root, &path) else {
let Some(bytes) = read(&path) else {
return;
};
match crate::bindings_xml::parse(&crate::source::decode(&bytes)) {
@@ -551,11 +578,15 @@ fn load_saved_variables(lua: &Lua, i: usize) {
/// `Addon::load_files`, and deliberately the same rules: `.lua` is a chunk, anything else is
/// FrameXML, and every reference resolves against the *including file's* directory (1186) inside
/// the AddOns root as the sandbox.
fn run_files(lua: &Lua, name: &str, root: &std::path::Path, files: &[String]) {
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
/// One addon's byte source, by a path relative to the AddOns root (`<Name>/<file>`) — the folder
/// reader for a player's addon, the chain reader for a Blizzard LoadOnDemand one.
type Reader = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in files {
let path = crate::loader::join_ref(name, file);
let Some(bytes) = read_under(root, &path) else {
let Some(bytes) = read(&path) else {
log_error(lua, &format!("{name}/{file}: not found"));
continue;
};
@@ -617,6 +648,11 @@ fn log_error(lua: &Lua, msg: &str) {
/// The host's push: the discovered registry and the root its files live under.
impl super::UiScript {
/// Seat the host's chain reader — what a chain-sourced addon (`AddOnInfo::chain`) is read
/// through, by chain-internal path (1957).
pub fn set_addon_chain_reader(&self, reader: AddonChainReader) {
self.model_mut().addons_chain_reader = Some(std::rc::Rc::from(reader));
}
/// Replace the AddOn registry — called once at world entry, after discovery
/// ([`crate::script`]'s `register_bindings` is the same shape: the host owns the facts, the
/// engine owns the verbs).
@@ -173,14 +173,42 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// the same field the setter below writes, which is table-level evidence rather than a
// name that happens to be in the image.
//
// Its sibling `GetMaxBytes` is in the image too and is NOT added: we do not model
// `maxBytes` at all (a separate field with a `-1` sentinel), and a getter for a field we
// do not have would answer confidently with a number that means nothing.
"GetMaxLetters",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.max_letters as i64)
})?,
)?;
m.set(
// `SetMaxBytes 0x798f30` — `SetMaxLetters`'s sibling in every respect but the sentinel:
// the same EXACT count gate (`0x798fbc cmp eax,2`, the second of the four `lua_gettop`
// callers), the same raw coerce of the value, and a field of its own at `+0x33c` whose
// no-limit value is **-1** where `maxLetters`'s is 0 (`0x799012 jle` → `0x799022`; the
// ctor writes -1 at `0x7799df`) — wow-re `numeric-arg-coercion-law.md` and `ui.md`'s
// CSimpleEditBox layout. So a non-positive argument is unlimited, a positive one caps
// the buffer's BYTES. The stock StaticPopup_Show calls it for any entry carrying
// `maxBytes` — none of the reference's own 76 does, so the reach is an addon's (1960).
"SetMaxBytes",
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() != 1 {
return Err(mlua::Error::runtime(
"Usage: <unnamed>:SetMaxBytes(maxBytes)",
));
}
let n = crate::script::binding_abi::coerced_number(lua, args.first().cloned());
let n = n as i64;
with_editbox(lua, &this, |eb| {
eb.max_bytes = (n > 0).then_some(n as usize);
})
})?,
)?;
m.set(
// The read half: -1 while unlimited, the reference's own stored sentinel.
"GetMaxBytes",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.max_bytes.map_or(-1, |n| n as i64))
})?,
)?;
m.set(
// `SetMaxLetters 0x799110` — one of only FOUR widget bindings in the whole registrar that
// calls `lua_gettop` (wow-re `numeric-arg-coercion-law.md` Q3), and its gate is EXACT:
@@ -1551,3 +1551,32 @@ fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
"a sign empties a numeric box rather than partly filling it"
);
}
/// `SetMaxBytes` (1960): the exact count gate and raw coerce of its sibling, a BYTE cap on the
/// buffer, and -1 as the no-limit sentinel where `SetMaxLetters` reads 0.
#[test]
fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E") E:SetFocus()"#)
.unwrap();
let max = |s: &UiScript| s.eval::<i64>("return E:GetMaxBytes()").unwrap();
assert_eq!(max(&s), -1, "unlimited from the ctor");
s.run("E:SetMaxBytes(4)").unwrap();
assert_eq!(max(&s), 4);
// Four bytes hold two two-byte letters and not a third.
s.run(r#"E:SetText("ééé")"#).unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "éé");
s.run(r#"E:SetMaxBytes(0) E:SetText("ééé")"#).unwrap();
assert_eq!(max(&s), -1, "a non-positive value is unlimited");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ééé");
s.run(r#"E:SetMaxBytes("3") E:SetText("abcd")"#).unwrap();
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"abc",
"a numeric string coerces"
);
s.run("E:SetMaxBytes(nil)").unwrap();
assert_eq!(max(&s), -1, "nil coerces to 0, which is unlimited");
assert!(s.run("E:SetMaxBytes()").is_err(), "too few raises");
assert!(s.run("E:SetMaxBytes(1, 2)").is_err(), "too many raises");
}
+20
View File
@@ -31,6 +31,10 @@ pub(crate) struct Model {
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// The host's reader for a chain-sourced addon's files (`AddOnInfo::chain`), by
/// chain-internal path — `None` in a VM with no chain behind it, where such an addon
/// answers `MISSING`.
pub(crate) addons_chain_reader: Option<super::addon::SharedChainReader>,
/// The host's font engine ([`super::UiScript::set_text_measurer`]) — what lets a metric read
/// answer inside the Lua call that asked, instead of a frame later. `None` in every VM without
/// a font atlas, which is the measure round-trip's own world and behaves exactly as it did.
@@ -477,6 +481,10 @@ pub(crate) struct Model {
/// Social intents (`AddFriend`/`RemoveFriend`/`SendWho`/…) queued since the app's last
/// [`super::UiScript::take_social_requests`] drain — the outbound seam ([`social`]).
pub(crate) social_requests: Vec<social::SocialRequest>,
/// `GetLookingForGroup`'s flag, written by `SetLookingForGroup` — the Who and Guild frames'
/// LFG check box. Client-side here; the wire (`CMSG_SET_LOOKING_FOR_GROUP`) waits on its
/// carve, and both local emulators ignore it anyway (1959).
pub(crate) looking_for_group: bool,
/// The guild snapshot the app pushes (roster, ranks, MOTD, info text — decision 1257):
/// `GetNumGuildMembers`/`GetGuildRosterInfo`/`GuildControlGetRankFlags`/… read it
/// ([`guild`]). Already display-resolved and already sorted + filtered, because the sort
@@ -808,6 +816,14 @@ pub(crate) struct Model {
pub(crate) pet_autocast_toggles: Vec<u32>,
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
pub(crate) pet_stop_attacks: u32,
/// The pet one-shot orders (`PetAttack`, `PetFollow`, `PetWait`, the three modes), each the
/// packed slot word the bar's own command or reaction slot would carry — the app's drain
/// presses them exactly as a slot (1958).
pub(crate) pet_orders: Vec<u32>,
/// `HasFullControl`'s flag — the reference's `[0xb4b3e4]`, written by
/// `SMSG_CLIENT_CONTROL_UPDATE` naming the local player and read as "if zero, refuse" by
/// every cast, item and cursor gate (wow-re `control-loss-and-restore.md`). Boot value 1.
pub(crate) player_control: bool,
/// Pet bar writes queued by the drag ([`cursor::pet`], decision 1010) — **one entry per
/// `CMSG_PET_SET_ACTION`**, each holding the one or two `(0-based position, packed word)` pairs
/// that send names. The nesting is the point: the server tells the one-pair form from the
@@ -1580,6 +1596,7 @@ impl Model {
Model {
addons: Vec::new(),
addons_root: None,
addons_chain_reader: None,
measurer: None,
texture_probe: None,
texture_size_probe: None,
@@ -1657,6 +1674,7 @@ impl Model {
reset_instance_asks: 0,
social: social::SocialState::default(),
social_requests: Vec::new(),
looking_for_group: false,
guild: guild::GuildState::default(),
guild_control: guild::GuildRankEdit::default(),
guild_requests: Vec::new(),
@@ -1735,6 +1753,8 @@ impl Model {
pet_actions_pressed: Vec::new(),
pet_autocast_toggles: Vec::new(),
pet_stop_attacks: 0,
pet_orders: Vec::new(),
player_control: true,
pet_set_actions: Vec::new(),
pet_abandons: 0,
pet_dismisses: 0,
+51
View File
@@ -1251,6 +1251,57 @@ mod tests {
.unwrap());
}
/// `UnitPlayerOrPetInParty`/`InRaid` (1958): a member's own guid, or a unit whose OWNER is a
/// member — the party's for the one, the raid roster's for the other; a stranger's pet is
/// nobody's; ungrouped is nil.
#[test]
fn unit_player_or_pet_in_party_reads_the_owner() {
let mut s = UiScript::new().unwrap();
s.set_party(two_member_party());
s.set_unit("player", Some(unit(true, 0x10)));
let mut alices_pet = unit(true, 0xC0FFEE);
alices_pet.owner = 0xA11CE;
s.set_unit("target", Some(alices_pet));
assert_eq!(
s.eval::<i64>(r#"return UnitPlayerOrPetInParty("target")"#)
.unwrap(),
1,
"Alice's pet is in the party by its owner"
);
assert!(
s.eval::<bool>(r#"return UnitPlayerOrPetInRaid("target") == nil"#)
.unwrap(),
"no raid: the raid twin says nil"
);
let mut strangers_pet = unit(true, 0xC0FFEE);
strangers_pet.owner = 0xDEAD;
s.set_unit("target", Some(strangers_pet));
assert!(s
.eval::<bool>(r#"return UnitPlayerOrPetInParty("target") == nil"#)
.unwrap());
// A member's own guid still answers, as UnitInParty does.
s.set_unit("target", Some(unit(true, 0xB0B)));
assert_eq!(
s.eval::<i64>(r#"return UnitPlayerOrPetInParty("target")"#)
.unwrap(),
1
);
// The raid twin over the raid roster.
s.set_party(ten_player_raid());
let mut raiders_pet = unit(true, 0xC0FFEE);
raiders_pet.owner = ten_player_raid().raid[0].guid;
s.set_unit("target", Some(raiders_pet));
assert_eq!(
s.eval::<i64>(r#"return UnitPlayerOrPetInRaid("target")"#)
.unwrap(),
1
);
s.set_party(crate::script::PartyState::default());
assert!(s
.eval::<bool>(r#"return UnitPlayerOrPetInParty("target") == nil"#)
.unwrap());
}
#[test]
fn unit_in_party_matches_roster_guids() {
let mut s = UiScript::new().unwrap();
+105
View File
@@ -25,6 +25,24 @@ use mlua::{Lua, MultiValue, Value};
use super::Model;
// The slot-word bytes the one-shot orders synthesize — the same values
// `benilla_protocol::messages::pet` names for the wire (the engine stays protocol-free, 0068 §3,
// so they are restated here rather than imported): the type byte, then the command / reaction.
const PET_ACT_COMMAND: u8 = 0x07;
const PET_ACT_REACTION: u8 = 0x06;
const PET_COMMAND_STAY: u32 = 0;
const PET_COMMAND_FOLLOW: u32 = 1;
const PET_COMMAND_ATTACK: u32 = 2;
const PET_REACT_PASSIVE: u32 = 0;
const PET_REACT_DEFENSIVE: u32 = 1;
const PET_REACT_AGGRESSIVE: u32 = 2;
/// A synthesized slot word: the type byte in the top byte, the action in the low bits — the
/// server's own packing (`PetActionEntry`).
const fn order(kind: u8, action: u32) -> u32 {
(kind as u32) << 24 | action
}
/// One pet bar slot, fully resolved by the app before pushing.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PetActionView {
@@ -265,6 +283,16 @@ impl super::UiScript {
std::mem::replace(&mut self.model_mut().pet_stop_attacks, 0)
}
/// Drain the pet one-shot orders (`PetAttack` & co.), each a packed slot word (1958).
pub fn take_pet_orders(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().pet_orders)
}
/// `HasFullControl`'s flag, from the host's control edge (`ui_unit::feed_player_control`).
pub fn set_player_control(&mut self, in_control: bool) {
self.model_mut().player_control = in_control;
}
/// Drain the pet bar writes the drag queued (decision 1010) — **one `Vec` per
/// `CMSG_PET_SET_ACTION`**, each of one or two `(0-based position, packed word)` pairs.
///
@@ -567,6 +595,41 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// PetStopAttack() — queue the call-off. No argument: the wire carries only the pet's guid.
// The six one-shot orders (`0x4be450`…`0x4be4a0`, wow-re `pet-action-bar-api.md` §10.9,
// carved 2026-09-03): each writes the literal slot word the bar's own reaction or command
// slot carries — `0x06000000`/1/2 PASSIVE/DEFENSIVE/AGGRESSIVE, `0x07000000`/1/2
// WAIT/FOLLOW/ATTACK — and re-enters `0x4bd1d0`, the identical call a `CastPetAction` press
// makes, PetAttack with the current selection's guid as a bar ATTACK click would. No
// arguments, no returns; the no-pet gate is silent, so a session without a pet bar has
// nothing to order and drops it, as the bar's own press would (1958; §10.9 confirmed the
// reading, including that a target-less PetAttack raises the bar press's own `ERR_ATTACK_*`
// after its auto-acquire — that leg is the drain's, shared with the press).
for (name, packed) in [
("PetPassiveMode", order(PET_ACT_REACTION, PET_REACT_PASSIVE)),
(
"PetDefensiveMode",
order(PET_ACT_REACTION, PET_REACT_DEFENSIVE),
),
(
"PetAggressiveMode",
order(PET_ACT_REACTION, PET_REACT_AGGRESSIVE),
),
("PetWait", order(PET_ACT_COMMAND, PET_COMMAND_STAY)),
("PetFollow", order(PET_ACT_COMMAND, PET_COMMAND_FOLLOW)),
("PetAttack", order(PET_ACT_COMMAND, PET_COMMAND_ATTACK)),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.pet_bar.has_bar {
model.pet_orders.push(packed);
}
Ok(())
})?,
)?;
}
g.set(
"PetStopAttack",
lua.create_function(|lua, ()| {
@@ -899,6 +962,48 @@ mod tests {
/// as a predicate and as the texture it passes to `SetItemButtonTexture`, so an empty string
/// standing in for the nil would draw a white square rather than the empty-slot art
/// (decision 1046's law, decision 1676's consumer).
/// The six one-shot orders synthesize the bar's own slot words (1958): the three reactions
/// as type 6 with the react byte, the three commands as type 7 with the command byte — the
/// same words `CastPetAction` on those slots would send. Without a bar there is nothing to
/// order, and none is queued.
#[test]
fn the_pet_one_shots_synthesize_the_bars_slot_words() {
let mut s = UiScript::new().unwrap();
s.run("PetAttack(); PetFollow(); PetWait(); PetPassiveMode()")
.unwrap();
assert!(s.take_pet_orders().is_empty(), "no bar, no order");
s.set_pet_actions(true, true, true, slots());
s.run(
"PetPassiveMode(); PetDefensiveMode(); PetAggressiveMode(); PetWait(); PetFollow(); \
PetAttack()",
)
.unwrap();
assert_eq!(
s.take_pet_orders(),
vec![
0x0600_0000,
0x0600_0001,
0x0600_0002,
0x0700_0000,
0x0700_0001,
0x0700_0002
]
);
assert!(s.take_pet_orders().is_empty(), "drained");
}
/// `HasFullControl()` is the host's control flag — 1 at boot, nil while a possession or fear
/// holds the body, 1 again after (1958).
#[test]
fn has_full_control_follows_the_hosts_flag() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
s.set_player_control(false);
assert!(s.eval::<bool>("return HasFullControl() == nil").unwrap());
s.set_player_control(true);
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
}
#[test]
fn the_pet_icon_answers_a_path_or_a_real_nil() {
let mut s = UiScript::new().unwrap();
+66
View File
@@ -207,6 +207,44 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetLookingForGroup() → four values, the first the flag (1 | nil); SetLookingForGroup(flag):
// the LFG check box's pair (`0x4e95d0` / `0x4e96b0`, registered; the getter's arity of FOUR
// is `reference/1.12-shapes.tsv`'s, its other three values and both bodies uncarved — a
// wow-re orchestrator is out, and the stock FrameXML reads only the first). The flag is
// client-side until the wire is read: `SetLookingForGroup` writes it and sends nothing yet,
// which is also what both local emulators do with the packet (1959).
g.set(
"GetLookingForGroup",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let flag = if model.looking_for_group {
Value::Integer(1)
} else {
Value::Nil
};
Ok(mlua::MultiValue::from_vec(vec![
flag,
Value::Nil,
Value::Nil,
Value::Nil,
]))
})?,
)?;
g.set(
"SetLookingForGroup",
lua.create_function(|lua, flag: Value| {
let on = match flag {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(i) => i != 0,
Value::Number(n) => n != 0.0,
_ => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.looking_for_group = on;
Ok(())
})?,
)?;
// ShowFriends() — ask the server for the list again.
g.set(
"ShowFriends",
@@ -423,3 +461,31 @@ fn clamp_index(index: i64, len: usize) -> u32 {
0
}
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The LFG pair (1959): the setter writes the flag, the getter answers it first of four —
/// the reference's arity — with nothing on the wire until the bodies are carved.
#[test]
fn the_looking_for_group_flag_round_trips_with_the_references_arity() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return select('#', GetLookingForGroup()) == 4")
.unwrap());
assert!(s
.eval::<bool>("return GetLookingForGroup() == nil")
.unwrap());
s.run("SetLookingForGroup(1)").unwrap();
assert_eq!(s.eval::<i64>("return (GetLookingForGroup())").unwrap(), 1);
s.run("SetLookingForGroup(nil)").unwrap();
assert!(s
.eval::<bool>("return GetLookingForGroup() == nil")
.unwrap());
assert!(
s.take_social_requests().is_empty(),
"nothing queued for the wire"
);
}
}
@@ -608,6 +608,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetTrainerServiceSkillLine(index) → the skill line the service teaches into, by name — the
// group the tree files it under (`TrainerService::group_name`: `SkillLine.dbc`'s name at
// trainer types 0/1/3, 1124's builder law). The stock window's one caller is the
// CONFIRM_PROFESSION dialog, which formats "learn <profession>?" with it
// (Blizzard_TrainerUI.lua l.19-24). A header row or an out-of-range index answers nil. The
// binding is registered (`0x4d9160`, 399 bytes) but its return law is not carved beyond
// "delegates"; this is the call site's reading (1957).
g.set(
"GetTrainerServiceSkillLine",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(n) = index.checked_sub(1) else {
return Ok(Value::Nil);
};
let Some(Row::Service(si)) = rows(&model).get(n).copied() else {
return Ok(Value::Nil);
};
let t = model
.trainer
.as_ref()
.expect("a visible row ⇒ an open trainer");
Ok(Value::String(
lua.create_string(&t.services[si].group_name)?,
))
})?,
)?;
// GetTrainerServiceIcon(index) → texture path (nil while in flight / OOB).
g.set(
"GetTrainerServiceIcon",
@@ -553,6 +553,61 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// string, unlike its `GetRaidRosterInfo` sibling.
// * **The player is in the roster array**, so no `t == "player"` special case is needed here
// — unlike `UnitInParty` below, whose roster excludes the recipient.
// HasFullControl() → 1 | nil: the reference's `[0xb4b3e4]` read at `0x51a158` (wow-re
// `control-loss-and-restore.md`) — the control flag `SMSG_CLIENT_CONTROL_UPDATE` writes for
// the local player, which the stock unit menu greys its follow/trade rows on (1958).
g.set(
"HasFullControl",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.player_control {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// UnitPlayerOrPetInParty(unit) / UnitPlayerOrPetInRaid(unit) → 1 | nil: the unit is a
// member of the group, or a member's pet — its owner (`UNIT_FIELD_SUMMONEDBY`, else the
// charmer, else the creator: `UnitState::owner`) is. The bindings are registered
// (`0x5162f0` / `0x5163b0`) and delegate to a C++ predicate wow-re has not carved; the
// owner reading is this file's, flagged in 1958.
for (name, raid) in [
("UnitPlayerOrPetInParty", false),
("UnitPlayerOrPetInRaid", true),
] {
g.set(
name,
lua.create_function(move |lua, token: Option<String>| {
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else {
return Ok(Value::Nil);
};
if !u.exists || u.guid == 0 {
return Ok(Value::Nil);
}
let me = model.unit("player").map(|p| p.guid).unwrap_or(0);
let in_group = |guid: u64| {
guid != 0
&& if raid {
model.party.raid.iter().any(|m| m.guid == guid)
} else {
guid == me || model.party.members.iter().any(|m| m.guid == guid)
}
};
let grouped = if raid {
!model.party.raid.is_empty()
} else {
!model.party.members.is_empty()
};
let hit = grouped && (in_group(u.guid) || in_group(u.owner));
Ok(if hit { Value::Integer(1) } else { Value::Nil })
})?,
)?;
}
g.set(
"UnitInRaid",
lua.create_function(|lua, token: Option<String>| {
+3
View File
@@ -198,6 +198,9 @@ pub struct UnitState {
/// `UNIT_FIELD_FLAGS`, raw — what `UNIT_FLAGS` fires on (the app's `fire_transitions`); the
/// three readings above are bits of it.
pub flags: u32,
/// The unit's owner — `UNIT_FIELD_SUMMONEDBY`, else its charmer, else its creator; `0` for
/// nobody's. What `UnitPlayerOrPetInParty`/`InRaid` read for the "or pet" half (1958).
pub owner: u64,
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see