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:
Sam
2026-08-31 20:15:16 +07:00
co-authored by Claude Fable 5
parent 961d7e46a0
commit a0d2fc383b
68 changed files with 2186 additions and 272 deletions
@@ -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`
+22 -1
View File
@@ -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
+74 -1
View File
@@ -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)]
+1 -1
View File
@@ -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;
+30 -9
View File
@@ -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,
+17
View File
@@ -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,
+2 -1
View File
@@ -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>| {
+59
View File
@@ -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>,
+285 -1
View File
@@ -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());
}