the unit frames are the reference's own, and the water has motes in it

- the player, party, target and pet frames are the reference's own files (`BuffFrame`, `UnitFrame`, `CombatFeedback`, `PlayerFrame`, `PartyFrame`, `TargetFrame`, `PetFrame`, in the reference's toc order) — our 2741-line `UnitFrames.xml` and our `PartyFrame.xml` are deleted; four engine verbs built for them (`PartialPlayTime`, `NoPlayTime`, `UnitPlayerControlled`, `UnitIsCivilian`, each answering 1/nil), the whole-UI load bracketed in the counted sound-suppression scope `0x48fbf0` self-brackets (the reference fires a `PlaySound` during load and throws it away), and `GetBillingTimeRested` read off `SMSG_AUTH_RESPONSE`'s third billing field rather than hardcoded; the differences the stock files bring are pinned rather than papered — `PlayerFrame` never hides, an unresolved name is BLANK not "Unknown", the target frame has no numerals at all (a bar hover pops the unit tooltip instead)
- what the swap surfaced, each a hard break under the stock files: `UnitBuff`/`UnitDebuff` returned the Classic Era ten-value tuple, name first, where 1.12 returns two and three values TEXTURE first (`0x519500`/`0x5198f0`) — a raise twice per `UNIT_AURA` on any debuffed target, and all 184 corpus call sites bind the 1.12 shape; the power events are named per resource (`UNIT_MANA`/`RAGE`/`FOCUS`/`ENERGY`/`HAPPINESS` and their MAX twins) and we fired the Era pair, so no mana, rage or energy bar could live-update; `SetRaidTarget` is the engine verb and `SetRaidTargetIcon` the FrameXML toggle wrapper — we had registered the wrapper's name on the engine's body, so every raid mark in the unit popup raised
- the underwater drift cloud: the reference draws a fixed 4000-mote field whenever the camera eye is inside a liquid — not a particle system, nothing spawns, ages or dies; a population scattered once into a 30 yd camera-relative cube, advected by the camera's own motion plus a gust and wrapped, so the field stands still in the world; benilla had the murk and nothing in it; a dry frame commits no draw, and the submerged one is bounded by the reference's own 666-quad cap (633–644 of 4000 measured live, which is the expected survivor count of a uniform cube through the 90° cone — an independent check on population, distribution and cone at once)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:04:32 +07:00
co-authored by Claude Fable 5.1
parent 6bafea0919
commit 7e3a67a02e
51 changed files with 2337 additions and 3467 deletions
+138 -20
View File
@@ -200,6 +200,38 @@ pub(super) fn cancel_authorized(a: &AuraState) -> bool {
/// nameplateShowPersonal, spellId`. The three we cannot know on this wire (`source`, `isStealable`,
/// `nameplateShowPersonal`) return nil — an addon that reads them sees "unknown", which is honest,
/// rather than a fabricated value.
/// The **1.12** return tuple, which is a different shape and not a prefix of the Era one: the first
/// value is the TEXTURE, not the name. `UnitBuff` returns `(texture, applications)` (`0x519500`) and
/// `UnitDebuff` returns `(texture, applications, dispelType)` (`0x5198f0`) — both `verified` in
/// wow-re's ledger, and both are what the reference's own FrameXML reads
/// (`TargetFrame.lua:287-290` binds `debuff, debuffStack, debuffType` and then
/// `SetTexture(debuff)`). Decision 1818, which also records why the Era shape was serving nobody.
fn returns_1121(lua: &Lua, a: &AuraState, with_dispel_type: bool) -> mlua::Result<MultiValue> {
let icon = match &a.icon {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
};
let mut out = vec![icon, Value::Integer(i64::from(a.count))];
if with_dispel_type {
out.push(match &a.debuff_type {
Some(t) => Value::String(lua.create_string(t)?),
None => Value::Nil,
});
}
Ok(MultiValue::from_vec(out))
}
/// Which return shape a getter wants — the two are not compatible, see [`returns_1121`].
#[derive(Clone, Copy)]
enum Shape {
/// `UnitAura`'s ten values. Era-only: the name does not exist in 1.12 FrameXML at all.
Era,
/// `UnitBuff`'s two.
Buff,
/// `UnitDebuff`'s three.
Debuff,
}
fn returns(lua: &Lua, a: &AuraState) -> mlua::Result<MultiValue> {
let s = |v: &Option<String>| -> mlua::Result<Value> {
Ok(match v {
@@ -229,6 +261,7 @@ fn nth_aura(
token: &Option<String>,
index: i64,
filter: &Filter,
shape: Shape,
) -> mlua::Result<MultiValue> {
if index < 1 {
return Ok(MultiValue::new());
@@ -246,7 +279,11 @@ fn nth_aura(
})
};
match hit {
Some(a) => returns(lua, &a),
Some(a) => match shape {
Shape::Era => returns(lua, &a),
Shape::Buff => returns_1121(lua, &a, false),
Shape::Debuff => returns_1121(lua, &a, true),
},
None => Ok(MultiValue::new()),
}
}
@@ -285,39 +322,62 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitAura(unit, index [, filter]) — filter defaults to HELPFUL, like the live API.
// UnitAura(unit, index [, filter]) — filter defaults to HELPFUL, like the live API. This one
// keeps the Era tuple: it is not a 1.12 verb at all (zero occurrences in the reference's
// FrameXML) and has zero call sites in the 110-addon corpus, so it costs nothing and stays the
// Era-compat surface 0068 asked for.
g.set(
"UnitAura",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
nth_aura(lua, &token, index, &Filter::parse(filter.as_deref()))
nth_aura(
lua,
&token,
index,
&Filter::parse(filter.as_deref()),
Shape::Era,
)
},
)?,
)?;
// UnitBuff/UnitDebuff(unit, index [, filter]) — the live API pins the sign and folds any extra
// filter tokens (CANCELABLE, …) in alongside it.
// UnitBuff(unit, index [, raidFilter]) / UnitDebuff(unit, index [, raidFilter]) — the 1.12
// signature and the 1.12 RETURN SHAPE (decision 1818). The sign is fixed by the verb, not by a
// filter word: `0x519500` reads aura slots 0..31 and `0x5198f0` reads 32..47.
//
// **The third argument is a `raidFilter` flag, not Era's filter string.** Non-zero enables ONE
// extra per-slot predicate — castable-by-me for buffs (`0x4b3870`), dispellable-by-me for
// debuffs (`0x4b3920`). We accept it and DO NOT apply it, because [`AuraState`] holds neither
// fact and neither is derivable from the aura feed: castable-by-me needs the player's spellbook,
// dispellable-by-me needs a class→dispel-type table joined to `debuff_type`. Inventing an answer
// is the guess 1203 exists to prevent, so the flag is parked here in the open. Visible
// consequence, so nobody has to rediscover it: the pet frame's buff row and the
// dispellable-debuff rows show everything rather than only what the player can cast or dispel.
g.set(
"UnitBuff",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
let spec = match filter {
Some(f) => format!("HELPFUL|{f}"),
None => "HELPFUL".to_string(),
};
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HELPFUL")),
Shape::Buff,
)
},
)?,
)?;
g.set(
"UnitDebuff",
lua.create_function(
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
let spec = match filter {
Some(f) => format!("HARMFUL|{f}"),
None => "HARMFUL".to_string(),
};
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|lua, (token, index, _raid_filter): (Option<String>, i64, Option<Value>)| {
nth_aura(
lua,
&token,
index,
&Filter::parse(Some("HARMFUL")),
Shape::Debuff,
)
},
)?,
)?;
@@ -429,16 +489,18 @@ mod tests {
.unwrap(),
"Battle Stance"
);
// `UnitBuff`/`UnitDebuff` enumerate the same list, but their first return is the ICON, not
// the name (1.12's shape — decision 1818), so they are addressed by it here.
assert_eq!(
s.eval::<String>(r#"return (UnitBuff("player", 2))"#)
.unwrap(),
"Mark of the Wild"
"Interface\\Icons\\Spell_1126"
);
// The debuff is index 1 of its own filter, not index 3.
assert_eq!(
s.eval::<String>(r#"return (UnitDebuff("player", 1))"#)
.unwrap(),
"Shadow Word: Pain"
"Interface\\Icons\\Spell_589"
);
// Past the end → nil, the loop terminator.
assert!(s
@@ -512,7 +574,7 @@ mod tests {
let (name, icon, count, dtype, dur, expiry, spell) = s
.eval::<(String, String, i64, String, f64, f64, i64)>(
r#"local n, i, c, d, du, e, src, st, np, sid = UnitDebuff("target", 1)
r#"local n, i, c, d, du, e, src, st, np, sid = UnitAura("target", 1, "HARMFUL")
assert(src == nil and st == nil and np == nil, "unknowable fields must be nil")
return n, i, c, d, du, e, sid"#,
)
@@ -524,6 +586,62 @@ mod tests {
assert_eq!(spell, 589);
}
/// The **1.12** shape, which is not a prefix of the Era one: `UnitBuff` → `(texture,
/// applications)`, `UnitDebuff` → `(texture, applications, dispelType)`, and the FIRST value is
/// the texture. Decision 1818; `0x519500`/`0x5198f0`, both `verified` in wow-re's ledger. The
/// reference's own FrameXML reads exactly this (`TargetFrame.lua:287-290`), and so does every
/// one of the 184 call sites in the addon corpus.
#[test]
fn unit_buff_and_unit_debuff_return_the_1121_tuple_not_the_era_one() {
let mut s = UiScript::new().unwrap();
let mut buff = aura(1126, "Mark of the Wild", true, true);
buff.count = 1;
let mut debuff = aura(589, "Shadow Word: Pain", false, false);
debuff.count = 3;
debuff.debuff_type = Some("Magic".into());
s.set_auras("target", Some(vec![buff, debuff]));
// UnitBuff returns exactly TWO values, texture first.
let (icon, count, third) = s
.eval::<(String, i64, Option<String>)>(
r#"local a, b, c = UnitBuff("target", 1) return a, b, c"#,
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\Spell_1126");
assert_eq!(count, 1);
assert_eq!(third, None, "UnitBuff returns two values, never a third");
// UnitDebuff returns THREE, the dispel type last.
let (icon, count, dispel, fourth) = s
.eval::<(String, i64, String, Option<String>)>(
r#"local a, b, c, d = UnitDebuff("target", 1) return a, b, c, d"#,
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\Spell_589");
assert_eq!((count, dispel.as_str()), (3, "Magic"));
assert_eq!(
fourth, None,
"UnitDebuff returns three values, never a fourth"
);
// The reference's own read, verbatim from TargetFrame.lua:287-297 — the comparison that
// raised `attempt to compare number with string` while we returned the Era tuple.
assert!(s
.eval::<bool>(
r#"local d, stack, dtype = UnitDebuff("target", 1)
return stack > 1 and dtype == "Magic""#
)
.unwrap());
// The raidFilter argument is ACCEPTED and its predicate is not applied (1818): passing it
// must not error, and must not change the answer.
assert_eq!(
s.eval::<String>(r#"return (UnitDebuff("target", 1, 1))"#)
.unwrap(),
"Interface\\Icons\\Spell_589"
);
}
#[test]
fn cancel_unit_buff_queues_the_spell_id_and_refuses_a_non_cancelable_aura() {
let mut s = UiScript::new().unwrap();
+9 -7
View File
@@ -412,15 +412,17 @@ pub const SCREEN: crate::layout::Handle = 0;
///
/// **This list is FLAT; the reference's set is per widget type** (RF-0028's script-name→slot
/// resolvers: base map `0x76a0d0` + the type's own additions — a `<Frame>` has no `OnClick`). That
/// divergence is deliberate and measured: our own transcribed FrameXML relies on it in 9 places
/// (`PlayerFrame`/`TargetFrame`/`PetFrame`/`PartyMemberFrame1-4` are mouse-enabled `<Frame>`s
/// carrying `OnClick` — see the note at `assets/ui/UnitFrames.xml`'s `PlayerFrame_OnClick` — plus
/// `ChatFrame1`/`ChatFrame2`), and the corpus in 21 more (20 `EditBox` + 1 `<Frame>`
/// divergence is deliberate and measured, and the 1751 migration has been shrinking the debt it
/// covers. Our own FrameXML used to rely on it in 9 places; seven of them were
/// `PlayerFrame`/`TargetFrame`/`PetFrame`/`PartyMemberFrame1-4` declared as mouse-enabled
/// `<Frame>`s carrying `OnClick` in our transcribed `UnitFrames.xml`, and that file is gone — the
/// reference declares all seven as `<Button>`, which carries `OnClick` by type. What is left of
/// ours is `ChatFrame1`/`ChatFrame2`, plus the corpus's 21 (20 `EditBox` + 1 `<Frame>`
/// `OnEscapePressed`), all of which fire today ([`button::click_button`]'s "plain frames can carry
/// one too" arm). Going per-type is the faithful shape and is what would give us `HasScript` (948
/// corpus call sites across 91 addons), but it removes working behaviour from those 30 sites, so it
/// is a change to make deliberately with FrameXML fixed first — not a side effect of widening this
/// list.
/// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain,
/// so it is still a change to make deliberately rather than as a side effect of widening this list
/// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now.
const SCRIPT_KINDS: [&str; 35] = [
"OnLoad",
"OnEvent",
+41
View File
@@ -592,6 +592,26 @@ pub(crate) struct Model {
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
pub(crate) sound_queue: Vec<SoundRequest>,
/// **The UI-load sound-suppression depth** — `[0xb05fa0]` in the reference, a counted scope
/// with exactly three references image-wide: `0x458f50` (`inc`), `0x458f60` (`dec`), and one
/// reader at `0x458046`, inside `PlaySoundByName 0x458030`. The whole-UI load `0x48fbf0`
/// brackets ITSELF in it — `0x48fbfa` on entry, `0x49016d` on exit, spanning the TOC walk,
/// `Bindings.xml` and the AddOns — and both of its callers, login (`0x48f681`) and `/reloadui`
/// (`0x495669`), therefore load silently.
///
/// The reader bails at `0x45804d` **before** the `MasterSoundEffects` CVar and the kit-hash
/// lookup, so a suppressed call is **dropped**: not muted, not queued, no Lua error, and the
/// binding still answers its usual `(willPlay, handle)`. Scoped to the NAME-keyed entry only —
/// id-keyed `PlaySound(kitId)` (`0x457fb0`/`0x457ff0`), `PlaySoundFile`, `PlayMusic` and
/// `StopMusic` all run normally inside the scope.
///
/// This is what makes stock `TargetFrame_OnHide`'s `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`
/// inaudible at every login: the frame really is shown when its `OnLoad` hides it, the OnHide
/// really does fire, the call really is made — and the engine throws it away. Decision 1033's
/// "no sound during the load" is the client's own law, enforced here rather than by hoping no
/// handler rings.
pub(crate) sound_suppression: u32,
/// The CVar table (decision 0954, [`super::cvars`]): lowercase name → slot. Host-registered
/// only; Lua reads/writes through `GetCVar`/`SetCVar`/`GetCVarDefault`.
pub(crate) cvars: HashMap<String, super::cvars::CvarSlot>,
@@ -1235,6 +1255,23 @@ pub(crate) struct Model {
pub(crate) rest_state: u8,
pub(crate) rest_pool: u32,
pub(crate) resting: bool,
/// `PLAYER_FLAGS` bit 12 — **PARTIAL_PLAY_TIME**, what `PartialPlayTime()` answers
/// (`0x48eb70`, `shr eax,0xc` / `and al,1`; decision 1746 §"`PLAYER_FLAGS` bit `0x1000` is
/// read after all"). The play-time regime a realm with an anti-addiction policy puts an
/// account into; on a western realm it never sets.
pub(crate) partial_play_time: bool,
/// `PLAYER_FLAGS` bit 13 — what `NoPlayTime()` answers (`0x48ebe0`, the neighbour of the
/// above and the harsher half of the same regime).
pub(crate) no_play_time: bool,
/// The account's accumulated **rested billing minutes**, from `SMSG_AUTH_RESPONSE` — what
/// `GetBillingTimeRested()` returns (decision 1820, binding `0x48ec50`). Minutes is the
/// server's convention and the engine converts nothing; stock `PlayerFrame.lua:246` divides by
/// 60 for hours. Reached only from inside the two play-time bits above, so with those clear
/// nothing in stock FrameXML ever reads it.
pub(crate) billing_time_rested: u32,
/// Is a cinematic playing? What `InCinematic()` reports, pushed by the cinematic plugin.
///
/// Read by more than the letterbox: `StaticPopup_Show` refuses any dialog whose entry lacks
@@ -1600,6 +1637,7 @@ impl Model {
action_bar_toggles: None,
action_bar_toggle_sends: Vec::new(),
sound_queue: Vec::new(),
sound_suppression: 0,
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
cvar_changes: Vec::new(),
@@ -1804,6 +1842,9 @@ impl Model {
rest_state: 2,
rest_pool: 0,
resting: false,
partial_play_time: false,
no_play_time: false,
billing_time_rested: 0,
in_cinematic: false,
exhaustion: [
(1, ("Rested".to_string(), 2.0)),
+11 -4
View File
@@ -205,7 +205,7 @@ pub enum PartyRequest {
},
/// `SetLootThreshold(n)` — the new quality floor.
LootThreshold(u32),
/// `SetRaidTargetIcon(unit, index)` — mark (1..=8) or clear (0) the raid-target icon on a
/// `SetRaidTarget(unit, index)` — mark (1..=8) or clear (0) the raid-target icon on a
/// unit, addressed by token; the app resolves the token to a guid for the
/// `MSG_RAID_TARGET_UPDATE` send (decision 0434 §5's submenu, §6's board law).
SetRaidTarget { unit: String, index: u8 },
@@ -561,8 +561,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// SetRaidTarget(unit, index) — the ENGINE verb. `SetRaidTargetIcon` is NOT one: it is FrameXML,
// `function SetRaidTargetIcon(unit, index)` at `TargetFrame.lua:486`, a toggle wrapper that
// calls this with 0 when the unit already wears `index`. We had registered the wrapper's name on
// this body, which behaved correctly and so never showed — until 1751 put `TargetFrame.lua` on
// the chain and the wrapper started calling a `SetRaidTarget` that did not exist. Proof both
// ways: no `function SetRaidTarget(` anywhere in the reference's FrameXML, and both
// `TargetFrame.lua:488`/`:490` and `Bindings.xml:1160` call it bare.
g.set(
"SetRaidTargetIcon",
"SetRaidTarget",
lua.create_function(|lua, (unit, index): (String, u8)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
@@ -1157,8 +1164,8 @@ mod tests {
#[test]
fn set_raid_target_icon_queues_the_token_and_index() {
let mut s = UiScript::new().unwrap();
s.run(r#"SetRaidTargetIcon("target", 8)"#).unwrap();
s.run(r#"SetRaidTargetIcon("party2", 0)"#).unwrap();
s.run(r#"SetRaidTarget("target", 8)"#).unwrap();
s.run(r#"SetRaidTarget("party2", 0)"#).unwrap();
assert_eq!(
s.take_party_requests(),
vec![
+10 -1
View File
@@ -85,7 +85,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
));
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.sound_queue.push(req);
// `PlaySoundByName 0x458030`'s first act: read the UI-load suppression depth
// (`0x458046`) and bail at `0x45804d` if it is non-zero — BEFORE the
// `MasterSoundEffects` CVar and before the kit-hash lookup. So the call is dropped
// rather than muted or queued, and the binding still answers as if it had played.
// NAME-keyed only: the id-keyed entry (`0x457fb0`) has no such gate, which is why the
// arm above pushes unconditionally.
let suppressed = matches!(req, SoundRequest::KitName(_)) && model.sound_suppression > 0;
if !suppressed {
model.sound_queue.push(req);
}
drop(model);
// willPlay (queued), soundHandle (nil — module docs).
Ok((true, Value::Nil))
@@ -323,6 +323,42 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// red/blue player legs, an NPC the reaction swatch. (The live gate is `UnitPlayerControlled`,
// which also covers pets/charmed creatures; we resolve only the player half of it — the extra
// reach needs a player-controlled flag we don't carry, and a player's own alt is the case here.)
// UnitIsCivilian(unit) → 1 if killing this unit would be a DISHONORABLE kill, else nil. This is
// `0x612550` itself handed to Lua — the same four-term predicate (`pvp` bit AND creature-query
// civilian flag AND hostile AND the kill would be grey) that the unit tooltip's green CIVILIAN
// line and `UnitPVPName`'s civilian arm already run through `is_civilian_kill`. That function's
// doc called itself "ONE home, two callers"; the stock target frame is the third, and it is the
// one that names the predicate — `TargetFrame_CheckDishonorableKill` (TargetFrame.lua:230) is
// its only FrameXML caller and the comment inside it reads "Is a dishonorable kill".
//
// Sharing the function is the point: a second copy would let the tooltip, the name and the
// target plate disagree about the same mob.
g.set(
"UnitIsCivilian",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let player_level = model.player_req.level;
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if super::is_civilian_kill(u, player_level) => Value::Integer(1),
_ => Value::Nil,
})
})?,
)?;
// UnitPlayerControlled(unit) → 1 if a PLAYER is driving this unit, else nil. `UNIT_FIELD_FLAGS`
// bit 3 (`UNIT_FLAG_PVP_ATTACKABLE 0x8`), which is wider than `UnitIsPlayer` below: a player's
// pet and a charmed creature answer 1 here and nil there. Stock `UnitFrame_OnEnter`
// (UnitFrame.lua:58) is the caller that made this a blocker rather than a nicety — it gates the
// player-options newbie tip on it, so a missing binding raised on the first unit-frame hover.
g.set(
"UnitPlayerControlled",
lua.create_function(|lua, unit: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match unit.and_then(|u| model.unit(&u)) {
Some(u) if u.player_controlled => Value::Integer(1),
_ => Value::Nil,
})
})?,
)?;
g.set(
"UnitIsPlayer",
lua.create_function(|lua, token: Option<String>| {
@@ -902,6 +938,54 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
// PartialPlayTime() / NoPlayTime() → 1/nil: the two anti-addiction play-time regimes, read
// straight off `PLAYER_FLAGS` — `0x48eb70` is `shr eax,0xc; and al,1` (bit 12) and `0x48ebe0`
// its bit-13 neighbour (decision 1746, which carved both while establishing that bit 0x1000 is
// PARTIAL_PLAY_TIME on 5875 rather than the pre-1.6.1 CAN_SELF_RESURRECT). Same Lua-vanilla
// predicate shape as `IsResting` above: the NUMBER 1 or nil, never a boolean. Stock
// `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) is the only FrameXML caller of either.
g.set(
"PartialPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.partial_play_time {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
g.set(
"NoPlayTime",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.no_play_time {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetBillingTimeRested() → the account's rested billing MINUTES, as one number, always
// (decision 1820; binding `0x48ec50`, byte-VERIFIED). The body has no `jcc` at all and ends in
// an unconditional `mov eax,1`, and both callees are branchless and call-free — so it can never
// return nil and never return zero values, which is why this is not the `Value::Nil` shape its
// neighbours use. The engine applies NO conversion: `0x48ec5e` is a bare unsigned u32→double
// push, so "minutes" is the server's convention, corroborated by stock `PlayerFrame.lua:246`
// dividing by 60 against `REQUIRED_REST_HOURS = 5`.
//
// Against vmangos this reads 0, because the server hardcodes `uint32(0)` for the field
// (`World.cpp:331`). It is fed from the wire rather than pinned to a constant so a server that
// populates it is reported honestly. Note the reference only ever reaches this call from
// INSIDE `if PartialPlayTime() ... elseif NoPlayTime()`, so with those bits clear stock
// FrameXML never calls it — it has to exist for the file to load, and is off the live path.
g.set(
"GetBillingTimeRested",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(Value::Number(f64::from(model.billing_time_rested)))
})?,
)?;
// GetTimeToWellRested() → nil, always — `0x48d4b0`, byte-VERIFIED: the whole binding is 11
// bytes, `pushnil; return 1`. FrameXML's EXHAUST_TOOLTIP4 countdown branch is dead in 5875.
g.set(
+51 -6
View File
@@ -182,6 +182,15 @@ pub struct UnitState {
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
pub is_player: bool,
/// This token is **player-CONTROLLED** — `UNIT_FIELD_FLAGS` bit 3
/// (`UNIT_FLAG_PVP_ATTACKABLE 0x8`, behaviourally "player-controlled"; the same bit
/// `target::relations::ring_reaction`'s duel leg selects on). Wider than
/// [`Self::is_player`]: a player's pet and a charmed creature are player-controlled without
/// being players, which is exactly the distinction stock `UnitFrame_OnEnter`
/// (UnitFrame.lua:58) needs when it decides whether the hovered plate gets the
/// player-options newbie tip. `UnitIsPlayer`'s note used to call this reach a gap we did not
/// carry; the unit-frame migration is what closed it.
pub player_controlled: bool,
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see
@@ -221,12 +230,16 @@ pub struct UnitState {
pub faction_name: Option<String>,
/// Connection to the server is live (`UnitIsConnected`) — an offline party member's portrait
/// desaturates (decision 0434 §2/§3: the `GROUP_LIST` status byte's `0x01` online bit).
/// **v1 default gap, stated not hidden:** the zero value is `false`, so a snapshot built via
/// `..Default::default()` reports a unit disconnected unless the app sets this explicitly; the
/// per-frame feed must set it `true` for every *live* unit token it pushes (mirroring
/// [`Self::exists`]) — a pending app-side change, not yet wired as of this field's addition. A
/// token absent from the store (`UnitExists` false) still reports nil regardless, via the same
/// "no snapshot" path every other predicate here uses.
/// **The zero value is `false`**, so a snapshot built via `..Default::default()` reports a unit
/// DISCONNECTED — which matters more than it reads: stock `UnitFrameManaBar_Update`
/// (UnitFrame.lua:213) greys a disconnected unit's bar and never reaches the power colour at
/// all. A synthetic unit that means to be live must say so.
///
/// The app's own feed does (`ui_unit::snapshot`, "the stated `is_connected` gap, closed"), and
/// the party feed reads the roster status byte's `0x01` for its tokens. This doc used to call
/// that "a pending app-side change, not yet wired"; it has been wired since, and the note
/// outlived it. A token absent from the store (`UnitExists` false) still reports nil
/// regardless, via the same "no snapshot" path every other predicate here uses.
pub is_connected: bool,
/// Away-from-keyboard (`UnitIsAFK` — decision 0434 §2's status-byte `0x40` bit). The party
/// frame's name line and chat both key off it. App-resolved; `false` until the feed lands.
@@ -451,6 +464,38 @@ impl super::UiScript {
model.resting = resting;
}
/// Push the two **play-time** bits of `PLAYER_FLAGS` — 12 (`PartialPlayTime`) and 13
/// (`NoPlayTime`), decision 1746. Taken together for the same reason the rest trio is:
/// stock `PlayerFrame_UpdatePlaytime` (PlayerFrame.lua:244) tests them as an if/elseif pair
/// and a half-updated pair would paint the wrong one of the two icons.
pub fn set_play_time(&mut self, partial: bool, none: bool) {
let mut model = self.model_mut();
model.partial_play_time = partial;
model.no_play_time = none;
}
/// Push the account's **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that admitted
/// the session — what `GetBillingTimeRested()` returns (decision 1820). Set once at login: the
/// client parks it in a process-lifetime global and nothing else on the wire ever writes it.
pub fn set_billing_time_rested(&mut self, minutes: u32) {
self.model_mut().billing_time_rested = minutes;
}
/// Enter the **UI-load sound-suppression scope** — the reference's `0x458f50`, an `inc` on the
/// counted depth every name-keyed `PlaySound` reads. Counted, not a flag, exactly as the
/// client has it: nesting is legal and only the outermost exit re-enables sound.
pub fn push_sound_suppression(&self) {
let mut model = self.model_mut();
model.sound_suppression = model.sound_suppression.saturating_add(1);
}
/// Leave it — `0x458f60`'s `dec`. Saturating on the way down too: an unbalanced pop is a bug
/// in the caller, and silently wrapping to a permanently-muted UI would be the worse failure.
pub fn pop_sound_suppression(&self) {
let mut model = self.model_mut();
model.sound_suppression = model.sound_suppression.saturating_sub(1);
}
/// Push whether a cinematic is playing — what `InCinematic()` answers.
pub fn set_in_cinematic(&mut self, playing: bool) {
self.model_mut().in_cinematic = playing;