Linux and Windows get their sound back, and emotes refuse while moving

- every build off macOS has been silent since the 09-02 snapshot (B356): the non-macOS device layer was a stub that opened nothing, and nothing had ever compiled the client for another target; that layer is now the same output stream over cpal — one seam, not two stacks: the ring, the render thread and the rebuild-on-device-change loop are one code on every platform, only the device file differs; the audio thread never allocates, a lost or changed default device is noticed at kira's own cadence, and realtime is `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows, either refusable, the ring turning a refusal into a degradation; and the two instruments this bug says were missing exist — a Linux and Windows cross-compile under `clippy -D warnings`, and a live output test that opens the real device and asserts the ring never ran dry, because compiling is not sounding (1920)
- an emote that requires standing still now refuses on the move: `CheckEmoteEligible`'s `0x4000` arm writes an out-param that `DoEmote` turns into a red "You can't do that while moving!" and aborts; 20 emotes of 78 can raise the line, read off the shipped `Emotes.dbc`; SWIMMING is not in the mask, so a swimmer holding still emotes fine, and `IsSelfControlled` is TRUE for an ordinary player, so a feared or confused player emotes away happily (1904); and the cast path gains its equipped-item rung — the button greyed and pressing it still sent: the reference short-circuits to "fits" on four gates, not one, the miss being a zero `EquippedItemSubClassMask`, which means no item required at all, and the rung sits above the crowd-control validator, so a player both stunned and missing the weapon is told about the weapon; that ladder has six arms, not three — CHARMED, FLEEING and CONFUSED join (1925)
- the quest reward's money is the reference's own widget: the tail of the shared `QuestFrameItems_Update` drives `questState.."MoneyFrame"` through `MoneyFrame_Update`, and the two hand-built coin trios become one `MoneyFrameTemplate` instance each at the reference's anchors, which our coins already used; the progress panel stays on coins, being the required-money flow, not the reward one (1928); and the flight master's `!` joins the questgiver sweep, because the green `!` and the gold `!` are the SAME slot, `unit+0xb2c`, torn down once before both queries re-issue; the mouseover re-ask is refuted — the only taxi-status sender has one live caller — and the two queries are not symmetric: the questgiver's goes out on every sweep, the taxi one only when the unit's key moves or on a full sweep, as the wire shows at Dungar Longdrink: 68 questgiver status queries against 1 taxi-node one (1918)
- `GetSpellTabInfo` out of range answers `nil, nil, 0, 0` — the reference returns four values on every path that returns (`0x4b3ce0`), and ours answered a single nil copied from another binding's shape; it matters because stock `SpellBookFrame_OnLoad` calls it unconditionally and then does `id > (offset + numSpells)`, which a nil raises on and (0+0) makes true, hiding every button on the page — the behaviour to match; the argument raises the reference's usage string when absent, and the index truncates toward zero THEN decrements (1931); the perf probe gains a `WOW_PROBE_UNCAP=vsync` arm, because the uncapped present mode is a request, not a schedule — measured, it taxes the number it was meant to pin (11.80 `cpu_ms` uncapped against 14.65 under it), so the default stays uncapped; and the last three test temp paths are keyed on the pid, after concurrent runs raced on one fixed shared file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:52:22 +07:00
co-authored by Claude Fable 5.1
parent 9f204d894b
commit b41acbf98a
40 changed files with 1745 additions and 426 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ impl super::UiScript {
}
/// The dressing-room pane's bake yaw in radians (the ref's `Model:SetRotation`), read by the
/// app onto the `"dressup"` booth slot each frame — the twin of [`Self::paperdoll_yaw`].
/// app onto the `"dressup"` booth slot each frame — the twin of `Self::paperdoll_yaw`.
pub fn dressup_yaw(&self) -> f32 {
self.model_ref().dressup_yaw
}
+2 -2
View File
@@ -20,7 +20,7 @@
//!
//! With the answer 0, that dropdown sized itself to 40px and seven character names drew outside
//! their own frame. The reference's own `SmallMoneyFrame` does the same thing (`MoneyFrame.lua`
//! l.202), which is why [`UiScript::set_digit_advances`](super::UiScript::set_digit_advances)
//! l.202), which is why `UiScript::set_digit_advances`
//! existed: a digits-only stand-in for exactly this, built because the general answer was missing.
//! This is the general answer, and it retires that special case.
//!
@@ -106,7 +106,7 @@ impl super::UiScript {
///
/// Call again to replace it — which the host must do whenever its **raster environment**
/// changes (window resize, `uiScale`), for the same reason
/// [`UiScript::forget_text_metrics`](super::UiScript::forget_text_metrics) exists: glyph
/// `UiScript::forget_text_metrics` exists: glyph
/// advances step to whole *physical* pixels, so a measurer built under one seam does not
/// answer for another.
pub fn set_text_measurer(&mut self, measurer: Box<dyn TextMeasure>) {
+1 -1
View File
@@ -427,7 +427,7 @@ pub(crate) struct Model {
pub(crate) target_nearest_friend_requests: Vec<bool>,
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
/// `Self::target_requests`, which takes unit tokens. The app runs the shared by-name
/// resolver (`crate::target::by_name`, decision 0886) and commits the selection ([`unit`]).
pub(crate) target_by_name_requests: Vec<(String, bool)>,
/// Set when `ClearTarget()` fired with a live target — the ESC chain's LAST leg
+103 -6
View File
@@ -444,14 +444,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetSpellTabInfo(i) -> name, texture, offset, numSpells (the Era flat tuple); 1-based `i`,
// out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape).
// GetSpellTabInfo(i) -> name, texture, offset, numSpells. **FOUR values on every path that
// returns** — `0x4b3ce0` has two live `ret`s and both `mov eax,4`; the `eax=0` one at `0x4b3d0d`
// is dead code after `luaL_error` longjmps. Byte-carved by a wow-re cross-check (decision 1931,
// `system/ui/scratch/spelltabinfo-return-contract.md`).
//
// **OUT OF RANGE IS `nil, nil, 0, 0` — LITERAL zeros, not a single nil.** This comment used to say
// "out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape)", i.e. one
// binding's shape copied onto another on the assumption that they generalise. They do not
// (1919 proved it for GetSkillLineInfo; this is the same bug one verb over). Leg `0x4b3e12`
// pushes nil, nil, then `push 0; push 0; lua_pushnumber` twice — `0x6f3810` copies its two
// stack dwords VERBATIM, so that is exactly +0.0, not a stale offset and nothing off the array
// base. Index 0, a negative, past-the-count and the empty/no-player registry all reach it via
// ONE unsigned branch (`0x4b3d26 dec ebx / cmp ebx,eax / jae`).
//
// Why the zeros are load-bearing rather than cosmetic: `SpellBookFrame_OnLoad` runs
// `SpellBookSkillLineTab_OnClick(1)` -> `GetSpellTabInfo(1)` UNCONDITIONALLY at load, and
// `SpellBookFrame_Update` can pass `SpellBookFrame:GetID()`, which is 0. The stock file then
// does `id > (offset + numSpells)` unguarded (l.295) — a nil there raises, and `(0+0)` makes it
// true, so the reference HIDES EVERY BUTTON ON THE PAGE. That is the behaviour to match.
//
// **The argument RAISES when absent or not number-coercible** — `luaL_error(L, "Usage:
// GetSpellTabInfo(index)")`, a numeric string accepted. [`number_arg`] is that exact shape
// (coerce, truncate toward zero, raise the usage string), and the decrement happens AFTER it.
// Do NOT share `GetSpellName`'s marshaller: `0x4b3ec0` does `fsub 1.0` BEFORE truncating, and
// the two disagree below 1 (`0.5` is out-of-range here and index 0 there).
//
// Slot 1 is also nil on an IN-RANGE tab whose skillLineId has no DBC row (`0x4b3dbd`), and
// `(string?, nil, num, num)` is live in shipped data (SkillLine.dbc 733/753/754 have
// spellIconID 0) — both already the shape the in-range arm below produces.
g.set(
"GetSpellTabInfo",
lua.create_function(|lua, i: usize| {
lua.create_function(|lua, i: Value| {
let i = super::binding_abi::number_arg(lua, i, "Usage: GetSpellTabInfo(index)")?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(tab) = i.checked_sub(1).and_then(|n| model.spellbook.tabs.get(n)) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
// Truncate (done), THEN decrement, then the unsigned compare: a negative or zero
// index lands below 0 and `usize::try_from` refuses it — the `jae` leg.
let tab = usize::try_from(i64::from(i) - 1)
.ok()
.and_then(|n| model.spellbook.tabs.get(n));
let Some(tab) = tab else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
]));
};
let texture = match &tab.texture {
Some(t) => Value::String(lua.create_string(t)?),
@@ -929,7 +967,11 @@ mod tests {
fn tab_info_shapes_and_book_id_offsets() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 0);
assert!(s.eval::<bool>("return GetSpellTabInfo(1) == nil").unwrap());
// `== nil` reads true either way — Lua compares only the first value. The ARITY is the
// load-bearing half and lives in `out_of_range_answers_four_values` below (1931).
assert!(s
.eval::<bool>("return (GetSpellTabInfo(1)) == nil")
.unwrap());
s.set_spellbook(book());
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 2);
@@ -1485,4 +1527,59 @@ mod tests {
1
);
}
/// **Out of range answers `nil, nil, 0, 0` — four values, the last two NUMBERS** (1931). The
/// count is the assertion: `GetSpellTabInfo(0) == nil` is true either way, which is exactly how
/// the old one-nil shape passed. Stock `SpellBookFrame.lua:295` does `id > (offset + numSpells)`
/// unguarded straight off `SpellBookFrame_OnLoad`, so slots 3+4 nil is a raise on load, and
/// `(0+0)` is what makes the reference hide every button — the behaviour this pins.
#[test]
fn out_of_range_answers_four_values() {
let s = UiScript::new().unwrap();
for idx in ["0", "1", "99", "-1", "0.5"] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', GetSpellTabInfo({idx}))"))
.unwrap(),
4,
"GetSpellTabInfo({idx}) must answer four values"
);
assert_eq!(
s.eval::<i64>(&format!(
"local _,_,o,n = GetSpellTabInfo({idx}) return o + n"
))
.unwrap(),
0,
"GetSpellTabInfo({idx}) slots 3+4 must be NUMBERS summing to 0"
);
assert!(s
.eval::<bool>(&format!("return (GetSpellTabInfo({idx})) == nil"))
.unwrap());
}
}
/// The argument is shape A (1931): absent or non-numeric RAISES the reference's own usage
/// string; a numeric STRING is coerced; a negative is truncated and falls to the fallback
/// rather than raising. `-1` and `0.5` are covered by the test above.
#[test]
fn tab_index_raises_when_absent_and_coerces_a_numeric_string() {
let mut s = UiScript::new().unwrap();
for bad in [
"GetSpellTabInfo()",
"GetSpellTabInfo(nil)",
"GetSpellTabInfo({})",
"GetSpellTabInfo('abc')",
] {
let err = s.run(bad).expect_err(bad);
assert!(
format!("{err}").contains("Usage: GetSpellTabInfo(index)"),
"{bad}: got {err}"
);
}
s.set_spellbook(book());
// "1" coerces exactly as 1 does — same name comes back.
assert_eq!(
s.eval::<String>("return (GetSpellTabInfo('1'))").unwrap(),
s.eval::<String>("return (GetSpellTabInfo(1))").unwrap()
);
}
}
+1 -1
View File
@@ -448,7 +448,7 @@ impl super::UiScript {
/// one GOLD line (the reference's engine SetText gold; a cross-interior dot renders FAINT
/// gold — the byte law's `|cffb0b0b0` wrap modulating the gold base, director-matched),
/// seated centred ABOVE the cursor: the tooltip's BOTTOM at the given UI-space point. The
/// plate FOLLOWS the pointer — [`Self::minimap_tooltip_move`] re-seats it as the cursor
/// plate FOLLOWS the pointer — `Self::minimap_tooltip_move` re-seats it as the cursor
/// drifts within one blip. Same world-owned fade lifecycle as the mouseover tooltip:
/// hover loss arms [`Self::world_tooltip_fade`].
pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool {