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