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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user