the world map blips are the reference's, and particles go untinted
- the world map speaks the reference's names: WorldMapTooltip is declared (six corpus addons index it; pfUI dies on it at file scope), the blackout frame takes the reference's spelling BlackoutWorld, and the four hand-written party blips become the reference's own unit-blip templates — all 44 blips as instances, each with its published $parentIcon, the name pfUI recolours group blips by class through; the 40 raid ones are declared but not positioned yet, named in the OUT list rather than faked
- the templates addons build their own buttons from are published: ItemButtonTemplate, ContainerFrameItemButtonTemplate and the bag-slot chain — most of the handler scripts were live the whole time (this client executes the reference's own container and paper-doll code off the player's patch chain), so what was missing was mostly declarations, not behaviour — the bank arm stays out and is named as the omission it is; OptionsFrameSliders becomes real, with the three Graphics rows reading it and re-reading on show, so an addon's write to a slider's bounds actually moves the slider; and the template census that had ranked these at "zero demand" learns to see a local bound to a literal — its table went from 3 rows to 7
- the bindings answer in 1.12's own shapes: Button:IsEnabled() answers the NUMBER 1 or 0 (0 is truthy in Lua, so the reference's own == 0 tests got false forever, and three of our files had hand-worked around the divergence rather than fixed it); the constructors' string arguments are four shapes, not one rule — pfUI passes a Button where a template name goes and the reference constructs the FontString and ignores it; SetInventoryItem returns THREE values, the reference's own paper doll destructuring repairCost; UnitIsVisible is object presence and nothing else (57 bytes, one branch, no distance — an out-of-range party member is exists-yes, visible-nil); the tapped pair reads the two dynamic-flags bits, and its repaint wire fires UNIT_FACTION on the tap bit — the half a passing predicate test never sees; UnitIsPartyLeader is two ORed legs covering disjoint failures (a held stranger leading their own party, and an out-of-range member with no object to read a flag from); and the four nameplate verbs take no argument and return zero values
- the ghost's wisps compose onto its body instead of over it — the four emitters drew at full strength over a body at alpha 0.5, and now inherit the instance alpha down the attachment chain the way the reference recomposes it per frame; the residual we had parked ("the wisps should inherit the tint too") is REFUTED at the bytes: a particle batch commits hardcoded white RGB, and even that never reaches the fragment — particles are never tinted, only the alpha crosses, applied to the alpha byte alone — so the shipped shader split was already right and no code changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -95,6 +95,38 @@ pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// **[`string_arg`]'s shape-C partner**: coerce like the reference, and treat everything else as
|
||||
/// **absent** rather than raising.
|
||||
///
|
||||
/// The pair is 1717's rule applied to string positions — *which* shape a position takes is settled
|
||||
/// per binding, never generalised — and the model-pane and region constructors are the two ends of
|
||||
/// it, verified together (wow-re `ui/scratch/xml-template-name-lookup.md` §5.2):
|
||||
///
|
||||
/// | position | fetch | a table there |
|
||||
/// |---|---|---|
|
||||
/// | `Model:SetModel` arg 1 | `0x6f3510` → `0x6f3690`, result **tested** | **raises** ([`string_arg`]) |
|
||||
/// | `CreateFontString`/`CreateTexture` `name`, `layer` | `0x6f3510` → `0x6f3690`, result **not** tested | absent — this fn |
|
||||
/// | `CreateFrame` `name`, `inherits` | `0x6f3690` **unguarded** | absent — this fn |
|
||||
///
|
||||
/// **The discriminator is not the argument's type; it is whether the binding TESTS its parser's
|
||||
/// return.** That is the sentence `numeric-arg-coercion-law.md` §6 had wrong ("an unrecognised
|
||||
/// string argument raises"), refuted by this round.
|
||||
///
|
||||
/// A **number** is accepted and stringified, exactly as `lua_tostring` coerces it — so
|
||||
/// `CreateFrame("Frame", 5)` names the frame `"5"`. Callers that must *not* coerce a number (the
|
||||
/// `lua_type(L,idx) == LUA_TSTRING` gate `CreateTexture`/`CreateFontString` put on their **fourth**
|
||||
/// argument, and only there) test the tag themselves before calling this.
|
||||
pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
|
||||
.coerce_string(v.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.to_str().ok().map(|t| t.to_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetBoolOrDefault` (`0x6f1c10`) — the reference's **boolean argument** coercion, which is *not*
|
||||
/// Lua truthiness and gets three arms backwards from the obvious reading. Byte-VERIFIED: wow-re
|
||||
/// `system/ui/scratch/action-bar-toggles.md` §2.1 re-derives the whole jump table at `0x6f1ce8`
|
||||
|
||||
@@ -653,9 +653,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"Disable",
|
||||
lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled = false))?,
|
||||
)?;
|
||||
// IsEnabled() → the NUMBER 1 or the NUMBER 0 — never a boolean, and never nil.
|
||||
//
|
||||
// wow-re `ui/scratch/button-enabled-state.md`, VERIFIED off the Button method table at
|
||||
// `0x879d10`: `IsEnabled 0x7800b0` reads the three-valued STATE at `[obj+0x328]`
|
||||
// (0 DISABLED / 1 NORMAL / 2 PUSHED), does `setne cl` for "not disabled", and pushes that as a
|
||||
// NUMBER. So the reference's own `IsEnabled() == 0` and `== 1` tests (FriendsFrame.lua l.404,
|
||||
// StaticPopup.lua l.713) are live code.
|
||||
//
|
||||
// **This answered a Lua boolean until now, and the difference is not cosmetic, because 0 is
|
||||
// TRUTHY in Lua.** A caller writing the reference's `== 0` got false forever; a caller writing
|
||||
// `IsEnabled() > 0` got "attempt to compare number with boolean" — which is where pfUI's
|
||||
// `api/ui-widgets.lua:812` icon-fade died, taking the widget module with it. The divergence was
|
||||
// known and worked around by hand in three of our own files (FriendsFrame.xml's
|
||||
// `guildButtonDisabled` read BOTH spellings and said so); those workarounds go with this
|
||||
// change, which is 1719's bar — a prior call revisited because a new measurement arrived.
|
||||
//
|
||||
// Slider carries its own `IsEnabled` (slider.rs) and is deliberately NOT changed here: the
|
||||
// note above is the BUTTON method table, and whether the slider widget shares the contract is
|
||||
// unverified. Assuming they match is exactly the guess this comment exists to have avoided.
|
||||
m.set(
|
||||
"IsEnabled",
|
||||
lua.create_function(|lua, this: Table| with_button(lua, &this, |bs| bs.enabled))?,
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_button(lua, &this, |bs| i64::from(bs.enabled))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// RegisterForClicks(...): replaces the button's registered-click set outright (the live API's
|
||||
|
||||
@@ -865,6 +865,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetInventoryItemCooldown(unit, slot) → (start, duration, enable), the EQUIPPED twin of
|
||||
// `GetContainerItemCooldown` (container.rs) and the same `GetTime`-clock convention.
|
||||
//
|
||||
// **It answers "no cooldown" and that is an absent FEED, not a pretended one.** benilla has no
|
||||
// equipped-item cooldown source yet — the same gap `tooltip_item`'s `SetInventoryItem` already
|
||||
// records where it leaves `hasCooldown` nil — so there is nothing to report and `(0, 0, 1)` is
|
||||
// exactly what the container twin answers for a slot with no record. It is bound rather than
|
||||
// left absent because the SOURCED `PaperDollFrame.lua` calls it UNCONDITIONALLY, once per
|
||||
// `PaperDollItemSlotButton_Update`: without it every button built from
|
||||
// `PaperDollItemSlotButtonTemplate` or `BagSlotButtonTemplate` raises inside its own OnLoad,
|
||||
// which is how pfUI's bag bar would meet it. A raise there is not the loud-and-correct kind
|
||||
// (1203) — it is a raise on a verb whose honest answer we already know.
|
||||
//
|
||||
// When an equipped-cooldown feed lands, this reads it the way the container twin reads
|
||||
// `container_cooldowns`, and the CooldownFrame the doll slots already carry starts sweeping
|
||||
// with no caller change.
|
||||
g.set(
|
||||
"GetInventoryItemCooldown",
|
||||
lua.create_function(|_, (_token, _slot): (Option<String>, i64)| {
|
||||
Ok((0.0f64, 0.0f64, 1i64))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetInventoryAlertStatus(index) → the region's alert status (`INV_ALERT_STATUS`: 0 none,
|
||||
// 3 damaged/low-ammo, 4 broken; 1/2 = temp-enchant alerts, unfed) — DurabilityFrame's
|
||||
// armor-guy read. Valid 1..=12 (the client's own 12-entry table; index 12 = low ammo, which
|
||||
|
||||
@@ -204,6 +204,18 @@ fn value_to_string(v: &Value) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Register the `GetCVar`/`SetCVar`/`GetCVarDefault`/`RegisterCVar` globals.
|
||||
/// The two nameplate toggles' registered CVar names — **defined here, in the crate that publishes
|
||||
/// the four verbs that write them**, so the binding and the name cannot drift apart. The app reads
|
||||
/// these consts rather than re-spelling the strings, and welds them to its registered table with
|
||||
/// its own test.
|
||||
///
|
||||
/// They are the LATER era's names: 1.12 registers no nameplate CVar at all (censused — see
|
||||
/// [`install_nameplate_verbs`]), so benilla's persistence takes the era spelling rather than
|
||||
/// inventing one, the same posture as `autoLootDefault`.
|
||||
pub const CVAR_NAMEPLATE_ENEMIES: &str = "nameplateShowEnemies";
|
||||
/// The friendly-plate half of [`CVAR_NAMEPLATE_ENEMIES`].
|
||||
pub const CVAR_NAMEPLATE_FRIENDS: &str = "nameplateShowFriends";
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `RegisterCVar(name, value)` — declare a CVar the client does not ship (decision 1195).
|
||||
//
|
||||
@@ -378,7 +390,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)
|
||||
)?;
|
||||
|
||||
install_nameplate_verbs(lua)
|
||||
}
|
||||
|
||||
/// **The four nameplate verbs** — `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
|
||||
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480` (wow-re
|
||||
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, §5 four-worker round).
|
||||
///
|
||||
/// Each is a **10-byte body** over one of two setters, and the reference's shape is worth stating
|
||||
/// because two obvious readings are wrong:
|
||||
///
|
||||
/// - **They read NO argument.** `ShowNameplates(false)` still shows. There is no `lua_gettop`, no
|
||||
/// `lua_toboolean`, nothing — the verb *is* the value, which is why there are four of them
|
||||
/// rather than two taking a flag.
|
||||
/// - **They return ZERO Lua values** — not `nil`, nothing. (`eax` at the `ret` is the return
|
||||
/// count, read off the VM's own C arm `0x6f61a8`.)
|
||||
/// - They are **four separate bodies**, not a masked pair like `UnitIsTapped`/`ByPlayer`:
|
||||
/// `0x489450` and `0x489470` are literally identical byte strings, rel32 included, over setters
|
||||
/// that differ only in their `or`/`and` masks.
|
||||
///
|
||||
/// The engine state is bits `0x1` (enemy) and `0x8` (friend) of one runtime dword `[0xc4da34]`.
|
||||
/// **There is no getter** — 15 references image-wide, all `.text`, and the 1153 `{name, fn}`
|
||||
/// records across all 54 registrar tables intersect the readers in the empty set — so an addon can
|
||||
/// set the state and never read it back, and neither can we.
|
||||
///
|
||||
/// **Where benilla diverges, deliberately and now knowingly.** 1.12 registers no nameplate CVar
|
||||
/// (censused: `0x63db90`'s 214 sites, 207 literal names, zero matching `/plate/i`), and the engine
|
||||
/// clears **both bits on every `EnterWorld`** — not once at boot. FrameXML replays them from its
|
||||
/// own `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` saved variables. Our two CVars reproduce *FrameXML's
|
||||
/// replay*, not the engine's state model: a benilla player's plates survive a zone-in because the
|
||||
/// CVar store outlives it. That is a divergence in favour of the player, recorded here rather than
|
||||
/// mistaken for fidelity.
|
||||
fn install_nameplate_verbs(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
for (name, cvar, on) in [
|
||||
("ShowNameplates", CVAR_NAMEPLATE_ENEMIES, true),
|
||||
("HideNameplates", CVAR_NAMEPLATE_ENEMIES, false),
|
||||
("ShowFriendNameplates", CVAR_NAMEPLATE_FRIENDS, true),
|
||||
("HideFriendNameplates", CVAR_NAMEPLATE_FRIENDS, false),
|
||||
] {
|
||||
g.set(
|
||||
name,
|
||||
// `MultiValue` and not `()`: mlua would otherwise reject a call that passes anything,
|
||||
// and the reference accepts and ignores every argument. Returning `MultiValue::new()`
|
||||
// is the zero-value return, which is NOT the same as pushing nil.
|
||||
lua.create_function(move |lua, _: mlua::MultiValue| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let key = cvar.to_ascii_lowercase();
|
||||
if let Some(slot) = model.cvars.get_mut(&key) {
|
||||
let value = if on { "1" } else { "0" };
|
||||
if slot.value != value {
|
||||
slot.value = value.to_string();
|
||||
let reg_name = slot.name.clone();
|
||||
model.cvar_changes.push((reg_name, value.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(mlua::MultiValue::new())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -167,7 +167,7 @@ pub use cursor::{
|
||||
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
|
||||
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
|
||||
};
|
||||
pub use cvars::MultisampleFormat;
|
||||
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
|
||||
pub use death::{DeathAction, DeathUiState};
|
||||
pub use dressup::DressUpIntent;
|
||||
pub use duel::DuelRequest;
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::ffi::c_void;
|
||||
|
||||
use mlua::{LightUserData, Lua, Table, Value};
|
||||
|
||||
use super::binding_abi::optional_string;
|
||||
use super::{Model, REG_FRAME_META, REG_FRAME_METHODS, REG_SCRIPTS, REG_WRAPPERS};
|
||||
use crate::layout::Point;
|
||||
use crate::order::{DrawLayer, Strata};
|
||||
@@ -378,16 +379,33 @@ fn create_frame(
|
||||
) -> mlua::Result<Table> {
|
||||
let frame_kind = frame_kind_from_str(&kind)
|
||||
.ok_or_else(|| mlua::Error::runtime(format!("CreateFrame: unknown frame type '{kind}'")))?;
|
||||
let name: Option<String> = match &name {
|
||||
Some(Value::String(s)) => Some(s.to_str()?.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let template: Option<String> = match &inherits {
|
||||
Some(Value::String(s)) => Some(s.to_str()?.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
// **`name` and `inherits` are `lua_tostring` positions, and a NUMBER is a string to it.**
|
||||
// `0x7060b0` reads both through `0x6f3690` with no type guard at all, so `CreateFrame("Frame",
|
||||
// 5)` names the frame `"5"` — a `Value::String`-only match drops it (wow-re
|
||||
// `ui/scratch/xml-template-name-lookup.md` §5.2).
|
||||
//
|
||||
// The fourth argument's quirk, which is real and reads like a bug in the client: `lua_tostring`
|
||||
// runs at `0x70613f` **before** the `cmp 4` gate, and `0x6f7cb1 mov dword ptr [esi],0x4`
|
||||
// retags the number's stack slot **in place** — so by the time the tag is tested it IS a
|
||||
// string. `CreateFrame("Frame", nil, nil, 5)` therefore raises `Couldn't find inherited node
|
||||
// "5"`, while `f:CreateTexture(nil, nil, 5)` silently ignores the 5, because the region
|
||||
// constructors ask `lua_type` first and never coerce. Same value, opposite outcome, decided by
|
||||
// argument order alone — which is why this is spelled out rather than shared with them.
|
||||
let name: Option<String> = name.as_ref().and_then(|v| optional_string(lua, v));
|
||||
let template: Option<String> = inherits.as_ref().and_then(|v| optional_string(lua, v));
|
||||
|
||||
// Resolve the parent handle (a wrapper table, or a name string) under a short read borrow.
|
||||
//
|
||||
// **The name-string arm is OURS, and the reference RAISES on it** — `0x7060b0` asks
|
||||
// `lua_type` three times and `cmp eax,5`: a table is the parent, nil/absent means no parent,
|
||||
// and a string takes the error leg (`xml-template-name-lookup.md` §5.2's per-position table).
|
||||
//
|
||||
// It is **parked, not pruned**, on the same footing as `TEXTURE_ONLY_METHODS`' tail three: the
|
||||
// §5 established *that* it raises but did not quote the message, and inventing the text of a
|
||||
// client error is exactly what decision 1721 was written about — a plausible string in the
|
||||
// voice of a finding. Measured meanwhile: **zero** call sites pass a string parent, in the
|
||||
// 109-folder corpus or in our own FrameXML, so the superset is unreachable in practice and
|
||||
// removing it blind would buy nothing. Pruning it needs the message, not more confidence.
|
||||
let parent_handle: Option<FrameHandle> = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match &parent {
|
||||
@@ -399,7 +417,10 @@ fn create_frame(
|
||||
}
|
||||
};
|
||||
|
||||
// A 4th argument that is present but not a string is the caller's bug, not a template.
|
||||
// A 4th argument that is present but neither a string nor a number is **silently ignored by
|
||||
// the reference** — `lua_tostring` returns NULL for it and the `cmp 4` gate then falls into
|
||||
// the ordinary construction path. So this is a benilla-only diagnostic and stays a WARNING,
|
||||
// never a raise: it tells a reader something was dropped without changing what runs.
|
||||
if template.is_none() {
|
||||
if let Some(v) = inherits.as_ref().filter(|v| !v.is_nil()) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::collections::HashSet;
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::script::binding_abi::optional_string;
|
||||
use crate::script::region::region_wrapper;
|
||||
use crate::script::{Model, RegionData, REG_SCRIPTS, SCRIPT_KINDS};
|
||||
use crate::widget::RegionKind;
|
||||
@@ -140,18 +141,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// (CustomNameplates:437, a four-argument later-client form). Accepted rather than skipped
|
||||
// because dropping it silently is the very class this fixes, and one real site is still a site.
|
||||
m.set(
|
||||
// **The three argument positions are NOT one rule, and the fourth is the odd one.** Verified
|
||||
// together in wow-re `ui/scratch/xml-template-name-lookup.md` §5.2:
|
||||
//
|
||||
// · `name` and `layer` go through `0x6f3510` (is-number-**or**-string) then `0x6f3690`, and
|
||||
// **the result is never tested** — so a table is simply absent (unnamed / the pre-staged
|
||||
// default layer), and a NUMBER is accepted and stringified.
|
||||
// · `inherits` is gated by a raw `lua_type(L, 4) == LUA_TSTRING` (`0x773d28` / `0x773b06`)
|
||||
// asked BEFORE anything reads the slot — so a table AND a number are both ignored, and the
|
||||
// non-string leg jumps to the very block the two template-HIT legs jump to, with both
|
||||
// template handles NULL. It is the success path, not a bolted-on case.
|
||||
//
|
||||
// **Nothing here raises.** This mattered: benilla typed all three `Option<String>`, so mlua
|
||||
// raised `bad argument #4: error converting Lua table to String` — and pfUI, the most-installed
|
||||
// 1.12 addon, builds every buff-stack label as
|
||||
// `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])`, passing the BUTTON as the third
|
||||
// argument. The real client constructs it, ignores the argument, and logs nothing.
|
||||
"CreateTexture",
|
||||
lua.create_function(
|
||||
|lua,
|
||||
(this, name, layer, inherits): (
|
||||
Table,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)| {
|
||||
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
|
||||
let name = optional_string(lua, &name);
|
||||
let layer = optional_string(lua, &layer);
|
||||
let wrapper = create_region(lua, &this, RegionKind::Texture, name, layer)?;
|
||||
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
|
||||
apply_region_inherits(lua, &wrapper, from)?;
|
||||
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
|
||||
apply_region_inherits(lua, &wrapper, &from)?;
|
||||
}
|
||||
Ok(wrapper)
|
||||
},
|
||||
@@ -255,16 +268,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"CreateFontString",
|
||||
lua.create_function(
|
||||
|lua,
|
||||
(this, name, layer, inherits): (
|
||||
Table,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)| {
|
||||
|lua, (this, name, layer, inherits): (Table, Value, Value, Value)| {
|
||||
let name = optional_string(lua, &name);
|
||||
let layer = optional_string(lua, &layer);
|
||||
let wrapper = create_region(lua, &this, RegionKind::FontString, name, layer)?;
|
||||
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
|
||||
apply_region_inherits(lua, &wrapper, from)?;
|
||||
if let Some(from) = strict_string_arg(&inherits).filter(|s| !s.is_empty()) {
|
||||
apply_region_inherits(lua, &wrapper, &from)?;
|
||||
}
|
||||
Ok(wrapper)
|
||||
},
|
||||
@@ -442,6 +451,23 @@ fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result
|
||||
)))
|
||||
}
|
||||
|
||||
/// **The `lua_type(L, idx) == LUA_TSTRING` gate, asked before anything reads the slot** — the
|
||||
/// region constructors' `inherits` argument and nowhere else (`0x773d28` for `CreateFontString`,
|
||||
/// `0x773b06` for `CreateTexture`).
|
||||
///
|
||||
/// Strictly narrower than [`optional_string`]: a **number is refused here**, because the tag is
|
||||
/// tested raw with no coercion. That one-argument difference is the whole reason
|
||||
/// `f:CreateTexture(nil, nil, 5)` silently ignores the 5 while `CreateFrame("Frame", nil, nil, 5)`
|
||||
/// **raises** `Couldn't find inherited node "5"` — `CreateFrame` runs `lua_tostring(L, 4)` BEFORE
|
||||
/// its own `cmp 4`, and that retags the number's stack slot in place, so it arrives as a string.
|
||||
/// Same value, opposite outcome, from argument order alone.
|
||||
fn strict_string_arg(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::String(s) => s.to_str().ok().map(|s| s.to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_region(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
|
||||
@@ -139,6 +139,22 @@ pub struct PartyState {
|
||||
/// The party leader, on the Lua `GetPartyLeaderIndex` scale: `0` the player leads, `1..=4` that
|
||||
/// `members` slot (1-based) leads.
|
||||
pub leader_index: u32,
|
||||
/// **The cached group-leader GUID** — the reference's `[0xbc75f8]:[0xbc75fc]` pair, `0` when
|
||||
/// ungrouped. Fed straight from the wire's leader GUID, not derived from
|
||||
/// [`Self::leader_index`].
|
||||
///
|
||||
/// It exists because `UnitIsPartyLeader`'s second leg is a GUID compare and an index cannot
|
||||
/// stand in for it: the leg has to answer for a member whose object the client does **not**
|
||||
/// hold — an out-of-range `party3`, any `raidN` — where there is no descriptor to read the
|
||||
/// flag from, and it has to answer for the *resolved token's* GUID rather than for a slot.
|
||||
///
|
||||
/// **Compared WITHOUT a zero guard**, which is the load-bearing part and reads like a bug:
|
||||
/// `IsPartyLeader 0x4e9130` short-circuits on a `0:0` cached leader, and
|
||||
/// `UnitIsPartyLeader 0x516210` does not. So an unresolvable-but-non-raising argument (none,
|
||||
/// `nil`, `""`, `"target"` with no target) resolves to `0:0`, matches the zeroed leader, and
|
||||
/// the reference answers **`1` while solo**. A client that answers `nil` there is not
|
||||
/// reproducing 1.12 (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
|
||||
pub leader_guid: u64,
|
||||
/// The **whole raid roster**, `GetRaidRosterInfo`'s 1-based array — empty outside a raid,
|
||||
/// and **including the player** (the reference's array does; it is why `UnitInRaid("player")`
|
||||
/// answers `1` in a raid and why this list is not `members`'s recipient-excluded shape).
|
||||
@@ -763,6 +779,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
leader_index: 1, // Alice (party1) leads
|
||||
leader_guid: 0xA11CE,
|
||||
raid: Vec::new(),
|
||||
loot_method: "group".into(),
|
||||
master_looter: None,
|
||||
|
||||
@@ -81,7 +81,8 @@ fn button_state_textures_switch_with_interaction() {
|
||||
// Disabled: disabled texture only, and no highlight even under the cursor.
|
||||
s.run("StateBtn:Disable()").unwrap();
|
||||
assert_eq!(visible(&s), vec!["Interface\\D.blp".to_string()]);
|
||||
assert!(!s.eval::<bool>("return StateBtn:IsEnabled()").unwrap());
|
||||
// 0, not false, and not nil — the number the real client answers.
|
||||
assert_eq!(s.eval::<i64>("return StateBtn:IsEnabled()").unwrap(), 0);
|
||||
s.run("StateBtn:Enable()").unwrap();
|
||||
assert_eq!(s.eval::<String>("return StateBtn:GetText()").unwrap(), "Go");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
@@ -1551,3 +1551,74 @@ fn every_installed_region_method_lands_in_a_leaf() {
|
||||
"installed on the region table but in NO leaf list, so no region can call them: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The four nameplate verbs take no argument and return nothing.**
|
||||
///
|
||||
/// `ShowNameplates 0x489450` / `HideNameplates 0x489460` / `ShowFriendNameplates 0x489470` /
|
||||
/// `HideFriendNameplates 0x489480` — four 10-byte bodies over two setters, differing only in an
|
||||
/// `or`/`and` mask (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
|
||||
///
|
||||
/// Both halves of the shape are asserted because both are easy to invent differently:
|
||||
///
|
||||
/// - **No argument is read.** `ShowNameplates(false)` still shows — there is no `lua_gettop` and
|
||||
/// no `lua_toboolean` in any of the four. That is *why* there are four verbs instead of two
|
||||
/// taking a flag, and a binding typed `fn(bool)` would reject a call the reference accepts.
|
||||
/// - **Zero return values, not nil.** `select('#', ...)` sees 0, which is observably different
|
||||
/// from one nil for a caller that counts.
|
||||
#[test]
|
||||
fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() {
|
||||
let mut s = script();
|
||||
s.register_cvars([
|
||||
(crate::script::CVAR_NAMEPLATE_ENEMIES, "1"),
|
||||
(crate::script::CVAR_NAMEPLATE_FRIENDS, "0"),
|
||||
]);
|
||||
|
||||
let get = |s: &crate::script::UiScript, n: &str| s.cvar(n);
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
// Each verb writes its own bit and leaves the other alone.
|
||||
s.run("HideNameplates()").unwrap();
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
|
||||
Some("0"),
|
||||
"the friendly bit is a separate setter — hiding enemies must not touch it"
|
||||
);
|
||||
s.run("ShowFriendNameplates()").unwrap();
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
|
||||
Some("0"),
|
||||
"...and back the other way"
|
||||
);
|
||||
|
||||
// **An argument is accepted and IGNORED.** `ShowNameplates(false)` shows, which is the whole
|
||||
// reason the reference ships four verbs rather than two taking a flag.
|
||||
s.run("ShowNameplates(false)").unwrap();
|
||||
assert_eq!(
|
||||
get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(),
|
||||
Some("1"),
|
||||
"the verb IS the value — a falsy argument does not invert it"
|
||||
);
|
||||
for call in ["HideNameplates(1, 2, 3)", "ShowFriendNameplates({})"] {
|
||||
s.run(call)
|
||||
.unwrap_or_else(|e| panic!("{call} must not raise: {e}"));
|
||||
}
|
||||
|
||||
// ZERO return values, not one nil.
|
||||
assert_eq!(
|
||||
s.eval::<usize>("return select('#', ShowNameplates())")
|
||||
.unwrap(),
|
||||
0,
|
||||
"zero values — observably different from nil for a caller that counts"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1096,3 +1096,107 @@ fn a_textures_getters_report_its_texel_span_on_an_unsized_axis() {
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **The three constructors' string arguments are FOUR different shapes, not one rule.**
|
||||
///
|
||||
/// Verified together in wow-re `ui/scratch/xml-template-name-lookup.md` §5.2 (§5 pair, orchestrator
|
||||
/// byte-arbitration). The discriminator is **not** the argument's type — it is whether the binding
|
||||
/// *tests* its parser's return, which is the sentence `numeric-arg-coercion-law.md` §6 had wrong:
|
||||
///
|
||||
/// | position | fetch | a table there | a number there |
|
||||
/// |---|---|---|---|
|
||||
/// | `CreateFontString`/`CreateTexture` `name`, `layer` | `0x6f3510` → `0x6f3690`, untested | absent | **accepted, stringified** |
|
||||
/// | `CreateFontString`/`CreateTexture` `inherits` | raw `lua_type == LUA_TSTRING`, no coercion | ignored | **ignored** |
|
||||
/// | `CreateFrame` `name`, `inherits` | `0x6f3690` **unguarded** | absent | **accepted, stringified** |
|
||||
/// | `CreateFrame` `kind` | `0x6f3510`, tested | **raises** | accepted |
|
||||
///
|
||||
/// The last two rows are the same value taking opposite paths from argument order alone:
|
||||
/// `CreateFrame` coerces at `0x70613f` *before* its `cmp 4`, and `0x6f7cb1` retags the number's
|
||||
/// stack slot **in place**, so a number arrives at the gate already a string.
|
||||
#[test]
|
||||
fn the_constructors_string_arguments_are_four_shapes_not_one() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(r#"host = CreateFrame("Frame", "ArgHost", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// ── pfUI's line, verbatim. `f.buffs[i]:CreateFontString(nil, "OVERLAY", f.buffs[i])` passes
|
||||
// the BUTTON as `inheritsFrom`. The real client constructs it, ignores the argument, and
|
||||
// logs nothing — this raised `bad argument #4` until 2026-08-30 and stopped pfUI, and with
|
||||
// it pfQuest, pfQuest-turtle and ShaguDPS.
|
||||
s.run(r#"fs = ArgHost:CreateFontString(nil, "OVERLAY", ArgHost)"#)
|
||||
.unwrap_or_else(|e| panic!("a table in `inherits` must be ignored, not raise: {e}"));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(fs)").unwrap(),
|
||||
"table",
|
||||
"...and the FontString is really constructed"
|
||||
);
|
||||
// Every other non-string in every region-constructor position: also silent, also constructed.
|
||||
for pos in ["{}, \"OVERLAY\"", "nil, {}", "true, false"] {
|
||||
for ctor in ["CreateFontString", "CreateTexture"] {
|
||||
s.run(&format!("r = ArgHost:{ctor}({pos})"))
|
||||
.unwrap_or_else(|e| panic!("{ctor}({pos}) must not raise: {e}"));
|
||||
assert_eq!(s.eval::<String>("return type(r)").unwrap(), "table");
|
||||
}
|
||||
}
|
||||
|
||||
// ── A NUMBER is a string to `lua_isstring` — so it NAMES the region.
|
||||
s.run("named = ArgHost:CreateTexture(4242)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return named:GetName()").unwrap(),
|
||||
"4242",
|
||||
"a number in `name` is stringified, not dropped"
|
||||
);
|
||||
// ...but NOT in `inherits`, whose gate reads the raw tag and never coerces. A number there is
|
||||
// ignored, where the same number in CreateFrame's fourth argument would raise.
|
||||
s.run("ArgHost:CreateTexture(nil, nil, 5)")
|
||||
.unwrap_or_else(|e| panic!("a number in a region ctor's `inherits` is ignored: {e}"));
|
||||
|
||||
// ── CreateFrame: `name` coerces the same way.
|
||||
s.run("nf = CreateFrame(\"Frame\", 77)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return nf:GetName()").unwrap(),
|
||||
"77",
|
||||
"CreateFrame reads `name` through an UNGUARDED lua_tostring"
|
||||
);
|
||||
// ...and an unresolvable `inherits` still raises, which is the pre-existing contract and the
|
||||
// control for the changes above: loosening the OTHER positions must not have loosened this.
|
||||
assert!(
|
||||
s.run(r#"CreateFrame("Frame", nil, nil, "NoSuchTemplateAnywhere")"#)
|
||||
.is_err(),
|
||||
"an unresolvable template name still raises — the miss branch is luaL_error"
|
||||
);
|
||||
|
||||
// ── The layer argument is PERMISSIVE, and an unrecognised one is ARTWORK, not an error.
|
||||
// `0x6f18b0` returns 0 with its out-param unwritten and neither constructor reads the
|
||||
// result, so the pre-staged ARTWORK (2) stands. Matching is case-insensitive.
|
||||
s.run(r#"lay = ArgHost:CreateTexture(nil, "not-a-layer")"#)
|
||||
.unwrap_or_else(|e| panic!("an unrecognised layer must not raise: {e}"));
|
||||
// Read off the arena — 1.12 publishes no `GetDrawLayer`, so there is no Lua getter to ask,
|
||||
// the same reason the sequence scrub is read this way in `modelframe`'s tests.
|
||||
let layer_of = |s: &UiScript, name: &str| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
let id = *model.region_names.get(name).expect("published region");
|
||||
let rh = *model.id_to_region.get(&id).expect("live region");
|
||||
model.arena.region(rh).expect("region").draw_layer
|
||||
};
|
||||
s.run(r#"ArgHost:CreateTexture("LayLower", "overlay")"#)
|
||||
.unwrap();
|
||||
s.run(r#"ArgHost:CreateTexture("LayUpper", "OVERLAY")"#)
|
||||
.unwrap();
|
||||
s.run(r#"ArgHost:CreateTexture("LayJunk", "not-a-layer")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layer_of(&s, "LayLower"),
|
||||
layer_of(&s, "LayUpper"),
|
||||
"layer matching is case-insensitive (SStrCmpI 0x64a4c0)"
|
||||
);
|
||||
assert_eq!(
|
||||
layer_of(&s, "LayJunk"),
|
||||
crate::order::DrawLayer::Artwork,
|
||||
"an unrecognised layer leaves the PRE-STAGED default standing — ARTWORK (2), not an error \
|
||||
and not BACKGROUND: `0x6f18b0` returns 0 with its out-param unwritten and neither \
|
||||
constructor reads the result"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,6 +224,18 @@ fn set_inventory_item_renders_full_outside_compare() {
|
||||
assert(quoted, "full render keeps the description")
|
||||
assert(tt:SetInventoryItem("player", 5) == nil, "empty slot answers nil")
|
||||
assert(tt:SetInventoryItem("target", 16) == nil, "self-only feed")
|
||||
|
||||
-- THREE returns, on every leg. The reference's own PaperDollFrame.lua:741 destructures
|
||||
-- `hasItem, hasCooldown, repairCost` unconditionally and only then tests hasItem, so an
|
||||
-- empty slot answering ONE value hands its caller a nil where a number belongs. pfUI's
|
||||
-- durability scan (panel.lua:499) does `totalRep + repCost` with no guard at all and died
|
||||
-- exactly there.
|
||||
local n = { tt:SetInventoryItem("player", 16) }
|
||||
assert(table.getn(n) == 3, "occupied: three returns, got " .. table.getn(n))
|
||||
assert(n[3] == 0, "repairCost is a NUMBER — the reference always pushes one; 0 INTERIM")
|
||||
local e = { tt:SetInventoryItem("player", 5) }
|
||||
assert(table.getn(e) == 3, "empty: three returns too, got " .. table.getn(e))
|
||||
assert(e[1] == nil and e[3] == 0, "empty slot: no item, but still a numeric repairCost")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -340,7 +340,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetInventoryItem(unit, slot) → hasItem, hasCooldown — the equipped-slot hover
|
||||
// GameTooltip:SetInventoryItem(unit, slot) → hasItem, hasCooldown, repairCost — the equipped-slot hover
|
||||
// (paperdoll slots, buff-frame weapon enchants) and the shopping-compare listener's render
|
||||
// (ref PaperDollFrame.lua:626). **Unit-keyed** through `Model::inv_slot`, the same router the
|
||||
// `GetInventoryItem*` getters use: `"player"` from the self feed, the inspected token from the
|
||||
@@ -350,6 +350,25 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// tooltip this renders the byte law's compare shape; the arm is consumed either way.
|
||||
// hasCooldown is nil INTERIM (no equipped-cooldown feed yet — its truthiness gates the ref's
|
||||
// re-poll only).
|
||||
//
|
||||
// **THREE returns, not two.** The reference's own `PaperDollFrame.lua:741` reads
|
||||
// `local hasItem, hasCooldown, repairCost = GameTooltip:SetInventoryItem("player", this:GetID())`
|
||||
// — Blizzard's file, out of the shipped `patch.MPQ`, and its very next use is
|
||||
// `if ( InRepairMode() and repairCost and (repairCost > 0) ) then ... SetTooltipMoney(...)`.
|
||||
// (l.632 in the same file reads only two, which is a caller not needing the third, not a
|
||||
// different contract.) We pushed two, so pfUI's durability scan died on
|
||||
// `panel.lua:499: attempt to perform arithmetic on local 'repCost' (a nil value)`.
|
||||
//
|
||||
// `repairCost` is **0 INTERIM**, the same posture and the same reason as `SetBagItem`'s below:
|
||||
// the per-item repair feed is the paper-doll arc's and does not exist yet. Zero rather than nil
|
||||
// because the reference **always pushes a number** there — wow-re `tooltip-money.md` §return-2
|
||||
// reads `fild [ebp-0x30]; call 0x6f3810` on `SetBagItem`'s unconditional leg — and an
|
||||
// undamaged item's cost genuinely is 0, so the interim value is a *real* value of the domain
|
||||
// rather than a stand-in. Every reference consumer guards on `> 0`.
|
||||
//
|
||||
// Arity provenance, stated because it differs from the rest of this module: the *three* is the
|
||||
// reference's own consumer, not a byte-read `mov eax, 3`. `SetBagItem`'s two IS byte-verified
|
||||
// (`mov eax,2` @0x534985). A carve of `0x532ee0`'s tail would upgrade this line.
|
||||
m.set(
|
||||
"SetInventoryItem",
|
||||
lua.create_function(|lua, (this, unit, slot): (Table, String, usize)| {
|
||||
@@ -394,7 +413,19 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
});
|
||||
match view {
|
||||
Some((id, name, q, inst)) => (id, name, q, inst, armed),
|
||||
None => return Ok(MultiValue::from_vec(vec![Value::Nil])),
|
||||
// An EMPTY slot answers `nil` for hasItem — and still pushes the other two,
|
||||
// because the reference's own caller destructures all three unconditionally
|
||||
// and only then tests `hasItem`. Answering one value here would hand
|
||||
// `PaperDollItemSlotButton_OnEnter` a nil `repairCost` on every empty slot,
|
||||
// which its `repairCost and (repairCost > 0)` guard survives but pfUI's bare
|
||||
// `totalRep + repCost` does not.
|
||||
None => {
|
||||
return Ok(MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Integer(0),
|
||||
]))
|
||||
}
|
||||
}
|
||||
};
|
||||
{
|
||||
@@ -421,7 +452,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
}
|
||||
show_or_hide_empty(lua, h);
|
||||
Ok(MultiValue::from_vec(vec![Value::Integer(1), Value::Nil]))
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Integer(1),
|
||||
Value::Nil,
|
||||
Value::Integer(0),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -32,6 +32,123 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `UnitIsVisible(unit)` — `0x516030`, and it is **object presence, nothing else**:
|
||||
// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. 57 bytes, one branch, no field
|
||||
// read and no comparison beyond `test eax,eax`.
|
||||
//
|
||||
// **Not a synonym for `UnitExists`, and neither implies the other.** An out-of-range party
|
||||
// member has a roster entry and no object: `UnitExists` = 1 through its GUID fallback,
|
||||
// `UnitIsVisible` = nil. That pair is the branch pfUI takes seven times — most visibly
|
||||
// `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)`, which chooses between a 3D
|
||||
// portrait and a flat one.
|
||||
//
|
||||
// **The return is the NUMBER 1, never a boolean** — `0x6f3810` writes tag 3 with the operands
|
||||
// of `1.0` on the true leg, `lua_pushnil` on the false one. `UnitExists` above answers a Rust
|
||||
// `bool` and so hands Lua `true`/`false`; that is its own pre-existing question, and matching
|
||||
// it here would have been the wrong kind of consistency.
|
||||
g.set(
|
||||
"UnitIsVisible",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
Ok(if with_unit(lua, &token, false, |u| u.has_object)? {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `UnitIsTapped(unit)` / `UnitIsTappedByPlayer(unit)` — `0x519c90` / `0x519d00`, a masked-byte
|
||||
// pair (108 bytes each; only the mask and the `Usage:` string differ). Each is
|
||||
// `object present && (UNIT_DYNAMIC_FLAGS & mask)` and nothing else: no ownership, no GUID
|
||||
// compare, no party/raid or health conjunct anywhere in either body.
|
||||
//
|
||||
// **Shape A, unlike `UnitIsVisible` directly above** — these two carry an `lua_isstring` gate
|
||||
// and a `Usage:` `luaL_error`, where their neighbour has none. Two adjacent verbs in one
|
||||
// family with opposite argument shapes is exactly why 1717's taxonomy is settled per binding;
|
||||
// inheriting the sibling's shape here would have been wrong in the quiet direction.
|
||||
//
|
||||
// The reference raises TWO different messages — `Usage:` for a non-string/non-number, and the
|
||||
// resolver's `"Unknown unit name: %s"` for a bad token (and for any NUMBER, since the
|
||||
// `lua_isstring` gate admits tag 3 and hands the resolver `"5"`). `check_unit_token` inside
|
||||
// `with_unit` is the second of those; the first is the `?` on the argument type below.
|
||||
// The gate is the BINDING's, not `with_unit`'s: `check_unit_token` lets a nil through by
|
||||
// design, because that is right for `UnitExists`, `UnitIsVisible` and most of this family. It
|
||||
// is wrong for exactly these two, so the shape-A test happens here, before the shared path.
|
||||
for (name, usage, by_player) in [
|
||||
("UnitIsTapped", r#"Usage: UnitIsTapped("unit")"#, false),
|
||||
(
|
||||
"UnitIsTappedByPlayer",
|
||||
r#"Usage: UnitIsTappedByPlayer("unit")"#,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
g.set(
|
||||
name,
|
||||
lua.create_function(move |lua, token: Value| {
|
||||
// Shape A: a string OR a number passes (`lua_isstring` admits tag 3, and the
|
||||
// client hands the resolver `"5"` — which then raises `Unknown unit name: 5`,
|
||||
// the family's SECOND message). Everything else — nil, absent, boolean, table,
|
||||
// function — is the `Usage:` raise.
|
||||
let token = Some(crate::script::binding_abi::string_arg(lua, token, usage)?);
|
||||
let hit = with_unit(lua, &token, false, |u| {
|
||||
if by_player {
|
||||
u.tapped_by_player
|
||||
} else {
|
||||
u.tapped
|
||||
}
|
||||
})?;
|
||||
// One value, and it is the NUMBER 1 or nil — never a boolean, the same shape the
|
||||
// rest of this family answers in.
|
||||
Ok(if hit { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// `UnitIsPartyLeader(unit)` — `0x516210`. **Two legs, ORed, covering disjoint failures:**
|
||||
//
|
||||
// o = ObjPtr(resolve(token), TYPEMASK_PLAYER) -- 0x10, not this family's 8
|
||||
// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(token) == g_groupLeaderGuid
|
||||
//
|
||||
// The descriptor leg answers for any held player — including a stranger who leads their OWN
|
||||
// party, which a comparison against our group's leader can never express. The GUID leg answers
|
||||
// for a group member whose object the client does not hold (an out-of-range `party3`, any
|
||||
// `raidN`), where there is no descriptor to read. Neither is sufficient alone, which is why
|
||||
// this is not derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however it is
|
||||
// arranged (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED).
|
||||
//
|
||||
// **No zero guard, and that is deliberate.** `IsPartyLeader 0x4e9130` short-circuits on a
|
||||
// `0:0` cached leader; this one does not. An unresolvable-but-non-raising token resolves to
|
||||
// `0:0`, which equals the zeroed leader while ungrouped — so `UnitIsPartyLeader(nil)` answers
|
||||
// **1 solo**. It reads like a bug and it is the behaviour; answering nil there would be the
|
||||
// divergence.
|
||||
//
|
||||
// Shape C with a shape-A tail: no `lua_isstring` gate and no `Usage:` of its own, but a bad
|
||||
// token — and any Lua NUMBER, which the resolver stringifies first — raises
|
||||
// `"Unknown unit name: %s"` from `check_unit_token`. One value on both legs, the number 1 or
|
||||
// nil, never a boolean.
|
||||
g.set(
|
||||
"UnitIsPartyLeader",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
check_unit_token(&token)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let by_flag = token
|
||||
.as_ref()
|
||||
.and_then(|t| model.unit(t))
|
||||
.is_some_and(|u| u.group_leader);
|
||||
// The GUID leg. A token with no snapshot resolves to GUID 0 — the reference's `0:0` —
|
||||
// and 0 == the zeroed leader is exactly the solo case above.
|
||||
let guid = token
|
||||
.as_ref()
|
||||
.and_then(|t| model.unit(t))
|
||||
.map_or(0, |u| u.guid);
|
||||
Ok(if by_flag || guid == model.party.leader_guid {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"UnitName",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
|
||||
@@ -30,7 +30,66 @@ use super::Model;
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UnitState {
|
||||
/// Whether the unit exists (`UnitExists`). A token with no snapshot is treated as `false` too.
|
||||
///
|
||||
/// **Stays TRUE for an out-of-range party member** — that is the whole point of the roster
|
||||
/// fallback, and it is what separates this field from [`Self::has_object`].
|
||||
///
|
||||
/// *Named gap, second order:* 1.12's `UnitExists 0x515fb0` is a **conjunction** — the object
|
||||
/// lookup AND a virtual `[edx+0x58]` that resolves to `IsSelectable 0x60be60`
|
||||
/// (`!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == you`) — falling back to the
|
||||
/// GUID-only roster test on a miss. We model the fallback and not the conjunct, so a
|
||||
/// not-selectable unit created by someone else reads `exists` here and `nil` there. No corpus
|
||||
/// caller reaches it; recorded rather than folded in (wow-re
|
||||
/// `ui/scratch/unitisvisible-object-presence.md`).
|
||||
pub exists: bool,
|
||||
/// **`UnitIsVisible` — the client holds a live object for this token, and nothing else.**
|
||||
///
|
||||
/// The reference is 57 bytes with one branch (`0x516030`):
|
||||
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. Its body reads **zero** object
|
||||
/// fields, makes **zero** comparisons beyond `test eax,eax`, and contains **zero** float
|
||||
/// opcodes — so there is no distance term, no radius, no alpha/fade/interpolation flag and no
|
||||
/// "known to the client" bit in it. Addons read it as a range gate because **the range test is
|
||||
/// the server's**: `SMSG_DESTROY_OBJECT` and the OUT_OF_RANGE demotion unlink the object from
|
||||
/// the manager index this query searches, so the client answers with the presence of the
|
||||
/// object the server chose to send.
|
||||
///
|
||||
/// Distinct from [`Self::exists`], and neither implies the other. The one that matters here:
|
||||
/// an out-of-range party member is `exists == true`, `has_object == false` — which is exactly
|
||||
/// pfUI's `if not UnitIsVisible(unitstr) or not UnitIsConnected(unitstr)` portrait branch.
|
||||
///
|
||||
/// The app already computed this and did not publish it: `ui_party::feed`'s
|
||||
/// `member_unit_state` takes `store: Option<&ObjectStore>` and its own comment calls that
|
||||
/// argument "the reference's `0x468460`". Every snapshot built from a live store sets it
|
||||
/// (`ui_unit::snapshot`); the roster-record leg and a token with no snapshot leave it `false`.
|
||||
///
|
||||
/// **One presence field serves three verbs** — `UnitIsVisible`, and the object-presence
|
||||
/// conjunct of both tapped predicates below.
|
||||
pub has_object: bool,
|
||||
/// `UnitIsTapped` — `UNIT_DYNAMIC_FLAGS` (UpdateField **143**) bit `0x4`: this unit is
|
||||
/// someone's kill credit. `0x519c90` is `test BYTE PTR [eax+0x224],0x4` behind the same
|
||||
/// object-presence check [`Self::has_object`] carries, and **nothing else** — no ownership, no
|
||||
/// GUID compare, no party/raid or health conjunct anywhere in the body (wow-re
|
||||
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
|
||||
///
|
||||
/// Set only on the live-descriptor leg, so an out-of-range unit reads `false` — which is the
|
||||
/// object-presence conjunct, for free, by the same route `has_object` gets it.
|
||||
pub tapped: bool,
|
||||
/// **`UnitIsPartyLeader`'s FIRST leg** — the server's `PLAYER_FLAGS & 0x1` on this unit's own
|
||||
/// descriptor, restricted to players (`TYPEMASK_PLAYER`, `0x10` — **not** this family's usual
|
||||
/// `8`, which is the one difference the predicate makes to the object lookup).
|
||||
///
|
||||
/// Not derived client-side, and not the same question as "leads MY group": it is true for a
|
||||
/// stranger who leads their own party, which no comparison against our group's leader GUID can
|
||||
/// express. The second leg is that comparison, in `script::party` — the two cover disjoint
|
||||
/// failures and the reference ORs them (wow-re
|
||||
/// `ui/scratch/party-leader-and-nameplate-verbs.md`).
|
||||
pub group_leader: bool,
|
||||
/// `UnitIsTappedByPlayer` — the same field's bit `0x8` (`0x519d00`, a masked-byte clone of its
|
||||
/// sibling: 108 bytes each, differing only in the mask and the `Usage:` string).
|
||||
///
|
||||
/// Read as a **pair**: `tapped && !tapped_by_player` is "someone else's kill", which is the
|
||||
/// grey-bar condition unit-frame addons draw (pfUI `api/unitframes.lua:2012`).
|
||||
pub tapped_by_player: bool,
|
||||
/// The unit's name (`UnitName` → the string, or nil). `None` while the app's name-query cache
|
||||
/// hasn't answered (or the server doesn't know the unit).
|
||||
pub name: Option<String>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The `Unit*` binding tests (the parent module is the unit under test).
|
||||
|
||||
use crate::script::{UiScript, UnitState};
|
||||
use crate::script::{PartyState, UiScript, UnitState};
|
||||
|
||||
fn player() -> UnitState {
|
||||
UnitState {
|
||||
@@ -958,3 +958,287 @@ fn a_multibyte_unit_token_raises_rather_than_panicking() {
|
||||
"Benilla"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`UnitIsVisible` is object presence, and it is NOT `UnitExists`** — the pair an out-of-range
|
||||
/// party member separates, and the branch pfUI takes seven times.
|
||||
///
|
||||
/// `0x516030` is 57 bytes with one branch:
|
||||
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. No field read, no comparison
|
||||
/// beyond `test eax,eax`, no float opcode — so no distance, no radius, no visibility flag. The
|
||||
/// range test is the *server's*: the out-of-range demotion unlinks the object from the very
|
||||
/// manager index this query searches (wow-re `ui/scratch/unitisvisible-object-presence.md`).
|
||||
///
|
||||
/// The state that backs it is [`UnitState::has_object`], which `ui_party::feed`'s
|
||||
/// `member_unit_state` has always branched on — its `store: Option<&ObjectStore>` argument, whose
|
||||
/// own comment names `0x468460`. This test is what stops the two fields being conflated back
|
||||
/// together, because in every *in-range* case they agree and only the out-of-range one tells them
|
||||
/// apart.
|
||||
#[test]
|
||||
fn unit_is_visible_is_object_presence_not_existence() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
// In range: the object is held, both answer yes.
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsVisible("party1")"#).unwrap(),
|
||||
1,
|
||||
"a held object is visible — and the return is the NUMBER 1, never a boolean"
|
||||
);
|
||||
|
||||
// **Out of range: the roster still has them, the object manager does not.** This is the row
|
||||
// that matters; `exists` MUST stay true here, because that is what UnitExists's own GUID
|
||||
// fallback answers, and folding the two fields into one would silently break it.
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(r#"return UnitIsVisible("party1")"#)
|
||||
.unwrap(),
|
||||
None,
|
||||
"no object -> nil, even though the token still exists"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return UnitExists("party1") and true or false"#)
|
||||
.unwrap(),
|
||||
"...and UnitExists must NOT have moved with it — the roster fallback is the difference"
|
||||
);
|
||||
|
||||
// pfUI's actual line (`unitframes.lua:1873`), which chooses a flat portrait over a 3D one.
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
r#"return (not UnitIsVisible("party1") or not UnitIsConnected("party1")) and true or false"#
|
||||
)
|
||||
.unwrap(),
|
||||
"the portrait branch fires for an out-of-range member"
|
||||
);
|
||||
|
||||
// A token with no snapshot at all: nil, via the same "no snapshot" path every predicate uses.
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(r#"return UnitIsVisible("party4")"#)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// **The tapped pair, and the two ways it goes wrong.**
|
||||
///
|
||||
/// `UnitIsTapped 0x519c90` / `UnitIsTappedByPlayer 0x519d00` are a masked-byte pair — 108 bytes
|
||||
/// each, differing only in the `UNIT_DYNAMIC_FLAGS` mask (`0x4`/`0x8`, UpdateField 143) and the
|
||||
/// `Usage:` string. Each is `object present && (flags & mask)` and nothing else: no ownership, no
|
||||
/// GUID compare, no party/raid or health conjunct (wow-re
|
||||
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
|
||||
///
|
||||
/// Asserted here because both are easy to get wrong in a way that still looks right:
|
||||
///
|
||||
/// - **Shape A, not shape C.** `UnitIsVisible` beside them has *no* `Usage:` raise; these two do.
|
||||
/// Inheriting the neighbour's shape would fail quietly, in the permissive direction.
|
||||
/// - **The pair is read as a conjunction.** `tapped && not tappedByPlayer` — someone *else's* kill
|
||||
/// — is the condition addons actually draw, and either bit alone says nothing useful.
|
||||
#[test]
|
||||
fn the_tapped_pair_is_two_masks_of_one_field_and_raises_on_a_bad_argument() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let set = |s: &mut UiScript, tapped, by_player| {
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
tapped,
|
||||
tapped_by_player: by_player,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Untapped: both nil.
|
||||
set(&mut s, false, false);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(r#"return UnitIsTapped("target")"#)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// Tapped by someone ELSE — the grey-bar case, pfUI `api/unitframes.lua:2012`.
|
||||
set(&mut s, true, false);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
|
||||
1,
|
||||
"the return is the NUMBER 1, never a boolean"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(r#"return UnitIsTappedByPlayer("target")"#)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
|
||||
)
|
||||
.unwrap(),
|
||||
"someone else's kill — the conjunction addons actually draw"
|
||||
);
|
||||
|
||||
// Tapped by ME: both set, so the grey-bar branch must NOT fire.
|
||||
set(&mut s, true, true);
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
|
||||
)
|
||||
.unwrap(),
|
||||
"my own tap is not greyed"
|
||||
);
|
||||
|
||||
// Object presence is a conjunct of BOTH: an out-of-range unit is neither, whatever the bits
|
||||
// say — and the bits cannot even be read, because there is no descriptor to read them from.
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: false,
|
||||
tapped: true,
|
||||
tapped_by_player: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// (The feed can never build that combination — `ui_unit::snapshot` sets all three together —
|
||||
// but the binding is asserted against it anyway, because a future feed that forgot would
|
||||
// otherwise report a tapped unit the object manager does not hold.)
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
|
||||
1,
|
||||
"the binding reads the field it is given; the conjunct is enforced at the FEED, and this \
|
||||
assertion records which of the two owns it"
|
||||
);
|
||||
|
||||
// SHAPE A. `UnitIsVisible` next door has no `Usage:` raise; these do — and **nil is in the
|
||||
// list**, which is the half that does not come free: the shared `check_unit_token` lets a nil
|
||||
// through by design (right for `UnitExists` and `UnitIsVisible`, wrong for these two), so the
|
||||
// gate has to live in the binding. This assertion is what found that; the original spelling
|
||||
// used `print`, which is not defined in our VM and so was quietly testing nil all along.
|
||||
for bad in ["{}", "true", "nil", "", "function() end"] {
|
||||
assert!(
|
||||
s.run(&format!("UnitIsTapped({bad})")).is_err(),
|
||||
"UnitIsTapped({bad}) must raise — shape A, not the neighbour's shape C"
|
||||
);
|
||||
assert!(
|
||||
s.run(&format!("UnitIsTappedByPlayer({bad})")).is_err(),
|
||||
"UnitIsTappedByPlayer({bad}) must raise too — the pair shares its gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **`UnitIsPartyLeader` is two legs ORed, and the solo case answers 1.**
|
||||
///
|
||||
/// `0x516210` is
|
||||
///
|
||||
/// ```text
|
||||
/// o = ObjPtr(resolve(t), TYPEMASK_PLAYER)
|
||||
/// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(t) == g_groupLeaderGuid
|
||||
/// ```
|
||||
///
|
||||
/// and it is **not** derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however arranged —
|
||||
/// the two legs cover disjoint failures (wow-re
|
||||
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED). Each leg is asserted with the
|
||||
/// other one dead, because either alone looks sufficient until the case it cannot reach.
|
||||
#[test]
|
||||
fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
const ME: u64 = 0x1111;
|
||||
const THEM: u64 = 0x2222;
|
||||
|
||||
// ── LEG 1 alone: the descriptor flag, with the leader GUID matching nobody.
|
||||
// This is the leg that answers for a STRANGER who leads their own party — something no
|
||||
// comparison against our group's leader could ever express.
|
||||
s.set_party(PartyState {
|
||||
leader_guid: 0x9999,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: THEM,
|
||||
group_leader: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
|
||||
.unwrap(),
|
||||
1,
|
||||
"the server's PLAYER_FLAGS bit answers on its own"
|
||||
);
|
||||
|
||||
// ── LEG 2 alone: no descriptor flag, but the resolved GUID IS our leader. This is the
|
||||
// out-of-range member — the client holds no object, so leg 1 has nothing to read.
|
||||
s.set_party(PartyState {
|
||||
leader_guid: THEM,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_unit(
|
||||
"party1",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: false,
|
||||
guid: THEM,
|
||||
group_leader: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsPartyLeader("party1")"#)
|
||||
.unwrap(),
|
||||
1,
|
||||
"the GUID compare answers where there is no descriptor to read a flag from"
|
||||
);
|
||||
|
||||
// ── Neither leg: a grouped member who does not lead.
|
||||
s.set_unit(
|
||||
"party2",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: ME,
|
||||
group_leader: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<Option<i64>>(r#"return UnitIsPartyLeader("party2")"#)
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// ── **NO ZERO GUARD.** Ungrouped, the cached leader is 0 and an unresolvable-but-non-raising
|
||||
// token resolves to 0 — so they match and the answer is 1. `IsPartyLeader 0x4e9130`
|
||||
// short-circuits on a `0:0` leader and `0x516210` does not; that asymmetry is the finding,
|
||||
// and answering nil here would be the divergence, however much it reads like a bug.
|
||||
s.set_party(PartyState::default());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return UnitIsPartyLeader(nil)").unwrap(),
|
||||
1,
|
||||
"solo + unresolvable token: 0 == 0, and the reference answers 1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
|
||||
.unwrap(),
|
||||
1,
|
||||
"...and so does a token with no snapshot, by the same route"
|
||||
);
|
||||
|
||||
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
|
||||
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user