the loading screen lets nothing through, and the micro buttons go stock
- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990) - the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987) - hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989) - a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -490,6 +490,8 @@ pub(crate) struct Model {
|
||||
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
|
||||
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
|
||||
pub(crate) party_requests: Vec<party::PartyRequest>,
|
||||
/// The ready-check deadline and the unanswered flags the timeout tick reads (decision 1989).
|
||||
pub(crate) ready_check: party::ReadyCheckState,
|
||||
/// The saved raid lockouts the Raid tab's info panel reads (decision 1549), pushed by the app
|
||||
/// from `SMSG_RAID_INSTANCE_INFO`. Its own field rather than a [`party::PartyState`] member
|
||||
/// because it arrives on its own packet and survives every roster change.
|
||||
@@ -1776,6 +1778,7 @@ impl Model {
|
||||
joined_channels: Vec::new(),
|
||||
party: party::PartyState::default(),
|
||||
party_requests: Vec::new(),
|
||||
ready_check: party::ReadyCheckState::default(),
|
||||
saved_instances: Vec::new(),
|
||||
raid_selection: 0,
|
||||
instance_type: None,
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::widget::{FrameHandle, FrameKind};
|
||||
mod events_regions;
|
||||
mod frame_state;
|
||||
mod layout_methods;
|
||||
pub(crate) use layout_methods::eff_scale;
|
||||
pub(crate) mod movable;
|
||||
pub(crate) mod toplevel;
|
||||
pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural};
|
||||
@@ -358,9 +359,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetCursorPosition() → x, y — the last cursor position the host fed (`mouse_move`/
|
||||
// `mouse_button`), UI units y-up like every other coordinate read. The real client scales by
|
||||
// the UI scale; ours is the constant 1 (`GetEffectiveScale`), so the ref's `/scale` dance is
|
||||
// an identity. The world map polls this every OnUpdate for hover/click math.
|
||||
// `mouse_button`), UI units y-up like every other coordinate read — the SCREEN's units, which
|
||||
// is what the reference hands Lua too; a caller inside a scaled frame divides by its
|
||||
// `GetEffectiveScale()` (the reference's own `MouseIsOver` does, and the stock world map at
|
||||
// a scale under 1 is the case that made the division load-bearing here — decision 1985). The
|
||||
// world map polls this every OnUpdate for hover/click math.
|
||||
lua.globals().set(
|
||||
"GetCursorPosition",
|
||||
lua.create_function(|lua, ()| {
|
||||
|
||||
@@ -312,7 +312,7 @@ fn settle(lua: &Lua) {
|
||||
crate::script::UiScript::resolve_layout(&mut model);
|
||||
}
|
||||
|
||||
pub(super) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
|
||||
pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
|
||||
let s = model
|
||||
.arena
|
||||
.frame(h)
|
||||
|
||||
@@ -155,6 +155,11 @@ pub struct PartyState {
|
||||
/// 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 active player's own GUID** — the reference's `0x468550` read (`[0xb41414]+0xc0`),
|
||||
/// `0` out of world. Fed by the app beside the leader; the pair is the leader gate the
|
||||
/// ready-check timeout runs on (`CheckReadyCheckTime`, decision 1989): the reference compares
|
||||
/// the two guids and nothing else, so a solo player (leader `0`, own non-zero) never matches.
|
||||
pub own_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).
|
||||
@@ -245,6 +250,62 @@ impl super::UiScript {
|
||||
/// job, never auto-fired here.
|
||||
pub fn set_party(&mut self, state: PartyState) {
|
||||
self.model_mut().party = state;
|
||||
// `SMSG_GROUP_LIST`'s ready-check leg (`0x4ba5f0`, reached only from the `0x7d` handler):
|
||||
// the roster scan over the NEW roster — a member who left took their pending flag with
|
||||
// them — and when nobody is left pending, a forced close (decision 1989).
|
||||
let lua = self.lua();
|
||||
let mut model = self.model_mut();
|
||||
if model.ready_check.deadline.is_some() {
|
||||
let roster: Vec<u64> = model.party.raid.iter().map(|m| m.guid).collect();
|
||||
model.ready_check.unanswered.retain(|g| roster.contains(g));
|
||||
if model.ready_check.unanswered.is_empty() {
|
||||
ready_check_force_close(lua, &mut model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `MSG_RAID_READY_CHECK` open form arrived (decision 1989). The handler `0x4ba360` splits
|
||||
/// on the leader guid: the **leader** takes the response-collection arm — reads the per-member
|
||||
/// records the body carries (none, on vmangos) and force-closes if nobody is left pending;
|
||||
/// everyone **else** arms the 30 s deadline (`0x4ba535`) that the leader-gated tick never
|
||||
/// acts on for them, and gets the `READY_CHECK` popup — which the app fires on its own
|
||||
/// ticket edge, so this method carries only the state half.
|
||||
pub fn ready_check_request(&mut self, we_lead: bool) {
|
||||
let lua = self.lua();
|
||||
let now = clock(lua);
|
||||
let mut model = self.model_mut();
|
||||
if we_lead {
|
||||
if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() {
|
||||
ready_check_force_close(lua, &mut model);
|
||||
}
|
||||
} else {
|
||||
model.ready_check.deadline = Some(now + READY_CHECK_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/// One member's answer, forwarded to the leader (`{guid, status}`, decision 1989): the
|
||||
/// member's "has not answered" flag clears (`0x4ba40e`), and once nobody is left pending the
|
||||
/// check closes at once (`0x4ba4ce` → the worker) rather than at the deadline.
|
||||
///
|
||||
/// **INFERRED, pending wow-re:** the flag clears on ANY answer, ready or not — the flag's
|
||||
/// meaning is "has not answered yet" (its writer census), and the summary the reference
|
||||
/// prints is headed "AFK", not "not ready". The polarity of the store at `0x4ba40e` against
|
||||
/// the wire byte is dispatched for a byte read; `ready` is carried here so that one line
|
||||
/// changes if the answer says otherwise.
|
||||
pub fn ready_check_answered(&mut self, guid: u64, _ready: bool) {
|
||||
let lua = self.lua();
|
||||
let mut model = self.model_mut();
|
||||
model.ready_check.unanswered.retain(|g| *g != guid);
|
||||
if model.ready_check.deadline.is_some() && model.ready_check.unanswered.is_empty() {
|
||||
ready_check_force_close(lua, &mut model);
|
||||
}
|
||||
}
|
||||
|
||||
/// The summary lines the timeout worker composed since the last drain — the reference's
|
||||
/// `0x49a870(text, 10)` = a `CHAT_MSG_SYSTEM` line the client prints itself; the app pushes
|
||||
/// them into the chat log the way every other client-composed system line goes.
|
||||
pub fn take_ready_check_lines(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().ready_check.lines)
|
||||
}
|
||||
|
||||
/// Drain the party/loot intents queued since the last call.
|
||||
@@ -676,11 +737,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"DoReadyCheck",
|
||||
lua.create_function(|lua, ()| {
|
||||
let now = clock(lua);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// The worker `0x4bb1d0` (decision 1989): every roster member except the caller is
|
||||
// flagged "has not answered" (`0x4bb24c`), the caller cleared (`0x4bb258`), the 30 s
|
||||
// deadline armed (`0x4bb2b6`) — then the packet. A party has no raid roster, so the
|
||||
// flags are empty there and the leader's own echo closes the check at once.
|
||||
let own = model.party.own_guid;
|
||||
model.ready_check.unanswered = model
|
||||
.party
|
||||
.raid
|
||||
.iter()
|
||||
.map(|m| m.guid)
|
||||
.filter(|g| *g != own)
|
||||
.collect();
|
||||
model.ready_check.deadline = Some(now + READY_CHECK_SECONDS);
|
||||
model.party_requests.push(PartyRequest::ReadyCheckStart);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// `CheckReadyCheckTime` (`0x4bc120` → `0x4bb310`) — the ready-check timeout tick, called from
|
||||
// the stock `UIParent.xml`'s `<OnUpdate>` every frame and driving the 30-second expiry alone
|
||||
// (nothing in the engine polls it). Four conjuncts, any failure writing nothing: a deadline is
|
||||
// armed, it has been reached, and the active player's guid equals the group leader's — a
|
||||
// LEADER gate, so it is observably inert for anyone else. On expiry it disarms the deadline
|
||||
// (the only clear-to-zero outside module init), lists every roster member still flagged as
|
||||
// unanswered, and prints `RAID_MEMBERS_AFK` with the `", "`-joined names — or
|
||||
// `READY_CHECK_NO_AFK` — as a `CHAT_MSG_SYSTEM` line. Both keys are read raw off `_G`
|
||||
// (`0x704350`: `lua_gettable` on GLOBALSINDEX, empty-string default), never through
|
||||
// `GetText`. Sends no packet, raises nothing, returns nothing (decision 1989; wow-re
|
||||
// `ui/scratch/uiparent-onupdate-engine-verbs.md` §3).
|
||||
g.set(
|
||||
"CheckReadyCheckTime",
|
||||
lua.create_function(|lua, ()| {
|
||||
let now = clock(lua);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
ready_check_tick(lua, &mut model, now);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// ConfirmReadyCheck(ready) — the popup's two buttons, and the argument is read for TRUTH, not
|
||||
// for a number: the reference's own No button calls it with no argument at all
|
||||
// (`ConfirmReadyCheck()`) while Yes passes `1`, so "absent" has to mean "not ready".
|
||||
@@ -772,6 +867,68 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ready-check deadline, `0x7530` ms after the arm (`0x4ba535`, `0x4bb2b6`).
|
||||
const READY_CHECK_SECONDS: f64 = 30.0;
|
||||
|
||||
/// The client's ready-check state (`RaidInfo.cpp`, decision 1989): the armed deadline
|
||||
/// (`[0xb713f4]`, `None` = disarmed) and the roster members whose "has not answered yet" flag
|
||||
/// (`[entry+0x158]`) is set, plus the summary lines the timeout worker composed and the app has
|
||||
/// not yet pushed into chat. Outside [`PartyState`] because a roster push replaces that whole
|
||||
/// snapshot and this survives one — the reference keeps the two in different tables too.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ReadyCheckState {
|
||||
pub(crate) deadline: Option<f64>,
|
||||
pub(crate) unanswered: Vec<u64>,
|
||||
pub(crate) lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// `GetTime()`'s clock — the session seconds `UiScript::tick` advances.
|
||||
fn clock(lua: &Lua) -> f64 {
|
||||
lua.globals().get("__benilla_now").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The timeout worker `0x4bb310` — see `CheckReadyCheckTime`'s note for the contract.
|
||||
fn ready_check_tick(lua: &Lua, model: &mut Model, now: f64) {
|
||||
let Some(deadline) = model.ready_check.deadline else {
|
||||
return;
|
||||
};
|
||||
if now < deadline {
|
||||
return;
|
||||
}
|
||||
if model.party.own_guid != model.party.leader_guid {
|
||||
return;
|
||||
}
|
||||
model.ready_check.deadline = None;
|
||||
// The roster walk: an unresolved name is silently skipped (`0x55f080` with its query arm
|
||||
// off), so a flagged member the roster cannot name contributes nothing to the list.
|
||||
let names: Vec<&str> = model
|
||||
.ready_check
|
||||
.unanswered
|
||||
.iter()
|
||||
.filter_map(|g| model.party.raid.iter().find(|m| m.guid == *g))
|
||||
.map(|m| m.name.as_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
let raw = |key: &str| lua.globals().get::<String>(key).unwrap_or_default();
|
||||
let text = if names.is_empty() {
|
||||
raw("READY_CHECK_NO_AFK")
|
||||
} else {
|
||||
raw("RAID_MEMBERS_AFK").replacen("%s", &names.join(", "), 1)
|
||||
};
|
||||
model.ready_check.lines.push(text);
|
||||
}
|
||||
|
||||
/// The two C-side force-close sites (`0x4ba4d4`, `0x4bacb4`): the deadline is set to `now − 1`
|
||||
/// and the worker runs at once. The arm test is theirs too — both force only an armed check.
|
||||
fn ready_check_force_close(lua: &Lua, model: &mut Model) {
|
||||
if model.ready_check.deadline.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = clock(lua);
|
||||
model.ready_check.deadline = Some(now - 1.0);
|
||||
ready_check_tick(lua, model, now);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::{PartyMemberInfo, PartyRequest, PartyState, UiScript};
|
||||
@@ -790,6 +947,7 @@ mod tests {
|
||||
],
|
||||
leader_index: 1, // Alice (party1) leads
|
||||
leader_guid: 0xA11CE,
|
||||
own_guid: 0x5E1F,
|
||||
raid: Vec::new(),
|
||||
loot_method: "group".into(),
|
||||
master_looter: None,
|
||||
@@ -1489,4 +1647,160 @@ mod tests {
|
||||
.eval::<bool>(r#"return GetRaidTargetIndex("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// ── The ready-check timeout (decision 1989) ─────────────────────────────────────────────
|
||||
|
||||
/// A raid we lead: us, Alice and Bob on the roster; the two strings the summary reads raw.
|
||||
fn raid_we_lead(s: &UiScript) -> PartyState {
|
||||
s.run(
|
||||
r#"RAID_MEMBERS_AFK = "The following players are AFK: %s"
|
||||
READY_CHECK_NO_AFK = "No players are AFK""#,
|
||||
)
|
||||
.unwrap();
|
||||
let row = |name: &str, guid: u64| crate::script::RaidMemberInfo {
|
||||
name: name.into(),
|
||||
guid,
|
||||
..Default::default()
|
||||
};
|
||||
PartyState {
|
||||
members: vec![
|
||||
PartyMemberInfo {
|
||||
name: "Alice".into(),
|
||||
guid: 0xA11CE,
|
||||
},
|
||||
PartyMemberInfo {
|
||||
name: "Bob".into(),
|
||||
guid: 0xB0B,
|
||||
},
|
||||
],
|
||||
leader_index: 0,
|
||||
leader_guid: 0x5E1F,
|
||||
own_guid: 0x5E1F,
|
||||
raid: vec![
|
||||
row("Probefour", 0x5E1F),
|
||||
row("Alice", 0xA11CE),
|
||||
row("Bob", 0xB0B),
|
||||
],
|
||||
loot_method: "group".into(),
|
||||
master_looter: None,
|
||||
loot_threshold: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoReadyCheck` flags every roster member but the caller and arms 30 s; the tick is a no-op
|
||||
/// before the deadline, prints the `", "`-joined unanswered names on it, and — the only
|
||||
/// clear-to-zero — disarms, so the next tick prints nothing.
|
||||
#[test]
|
||||
fn the_leaders_check_times_out_into_the_afk_list_and_disarms() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let party = raid_we_lead(&s);
|
||||
s.set_party(party);
|
||||
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
|
||||
assert_eq!(s.take_party_requests(), vec![PartyRequest::ReadyCheckStart]);
|
||||
|
||||
s.run("__benilla_now = 129.9 CheckReadyCheckTime()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.take_ready_check_lines().is_empty(),
|
||||
"not before the deadline"
|
||||
);
|
||||
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_ready_check_lines(),
|
||||
vec!["The following players are AFK: Alice, Bob".to_string()]
|
||||
);
|
||||
s.run("__benilla_now = 200 CheckReadyCheckTime()").unwrap();
|
||||
assert!(
|
||||
s.take_ready_check_lines().is_empty(),
|
||||
"disarmed by the summary"
|
||||
);
|
||||
}
|
||||
|
||||
/// An answer — ready or not — clears its member's flag; the answer that leaves nobody pending
|
||||
/// closes the check at once (`0x4ba4d9`), well before the deadline.
|
||||
#[test]
|
||||
fn an_answer_clears_its_member_and_the_last_one_closes_the_check_at_once() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let party = raid_we_lead(&s);
|
||||
s.set_party(party);
|
||||
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
|
||||
s.ready_check_answered(0xA11CE, false);
|
||||
assert!(
|
||||
s.take_ready_check_lines().is_empty(),
|
||||
"Bob is still pending"
|
||||
);
|
||||
s.ready_check_answered(0xB0B, true);
|
||||
assert_eq!(
|
||||
s.take_ready_check_lines(),
|
||||
vec!["No players are AFK".to_string()],
|
||||
"nobody left pending ⇒ the summary now, not at 130"
|
||||
);
|
||||
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
|
||||
assert!(s.take_ready_check_lines().is_empty());
|
||||
}
|
||||
|
||||
/// The tick is leader-gated: a member's client arms the same 30 s on the open form and the
|
||||
/// four conjuncts fail on the guid compare, every frame, forever — the deadline stays armed
|
||||
/// (nothing on the member's side ever clears it) and nothing is ever printed.
|
||||
#[test]
|
||||
fn the_tick_is_inert_for_a_non_leader() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(r#"READY_CHECK_NO_AFK = "No players are AFK""#)
|
||||
.unwrap();
|
||||
s.set_party(two_member_party()); // Alice leads; we are 0x5E1F
|
||||
s.run("__benilla_now = 100").unwrap();
|
||||
s.ready_check_request(false);
|
||||
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
|
||||
assert!(s.take_ready_check_lines().is_empty());
|
||||
assert_eq!(s.model_mut().ready_check.deadline, Some(130.0));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_LIST`'s leg: a member who left took their pending flag with them, and a roster
|
||||
/// scan that finds nobody pending force-closes the check.
|
||||
#[test]
|
||||
fn a_roster_change_with_nobody_left_pending_closes_the_check() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let party = raid_we_lead(&s);
|
||||
s.set_party(party.clone());
|
||||
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
|
||||
s.ready_check_answered(0xA11CE, true);
|
||||
// Bob leaves: the roster without him.
|
||||
let mut without_bob = party;
|
||||
without_bob.raid.retain(|m| m.guid != 0xB0B);
|
||||
without_bob.members.retain(|m| m.guid != 0xB0B);
|
||||
s.set_party(without_bob);
|
||||
assert_eq!(
|
||||
s.take_ready_check_lines(),
|
||||
vec!["No players are AFK".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain party has no raid roster, so `DoReadyCheck` flags nobody — and the leader's own
|
||||
/// echo of the open form, taking the response-collection arm, closes it on the spot.
|
||||
#[test]
|
||||
fn the_leaders_own_echo_closes_an_empty_check_at_once() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut party = raid_we_lead(&s);
|
||||
party.raid.clear();
|
||||
s.set_party(party);
|
||||
s.run("__benilla_now = 100 DoReadyCheck()").unwrap();
|
||||
s.ready_check_request(true);
|
||||
assert_eq!(
|
||||
s.take_ready_check_lines(),
|
||||
vec!["No players are AFK".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// The two keys are read raw off `_G` with an empty-string default — a missing GlobalStrings
|
||||
/// prints an empty line and never raises.
|
||||
#[test]
|
||||
fn a_missing_summary_string_prints_an_empty_line() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let party = raid_we_lead(&s);
|
||||
s.set_party(party);
|
||||
s.run("RAID_MEMBERS_AFK = nil __benilla_now = 100 DoReadyCheck()")
|
||||
.unwrap();
|
||||
s.run("__benilla_now = 130 CheckReadyCheckTime()").unwrap();
|
||||
assert_eq!(s.take_ready_check_lines(), vec![String::new()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +86,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
||||
)?;
|
||||
|
||||
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin
|
||||
// in object.rs). Every drawable region carries anchors (authored or the creation-path
|
||||
// implicit anchor, decision 1310) and reads its resolved rect; a templateless Lua region
|
||||
// nobody anchored never resolves → nil, same as pre-resolve.
|
||||
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges in its OWNER's units (y-up;
|
||||
// screen ÷ the owner's effective scale, the frame twin's law in `object/layout_methods.rs` —
|
||||
// a region shares its owner's scale, and a texture inside the scaled world map answered
|
||||
// screen units here while its owner answered local ones, decision 1985). Every drawable
|
||||
// region carries anchors (authored or the creation-path implicit anchor, decision 1310) and
|
||||
// reads its resolved rect; a templateless Lua region nobody anchored never resolves → nil,
|
||||
// same as pre-resolve.
|
||||
for (name, pick) in [
|
||||
("GetLeft", 0u8),
|
||||
("GetRight", 1u8),
|
||||
@@ -101,11 +104,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
lua.create_function(move |lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_resolved.get(&rh).map(|r| match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
let inv = 1.0 / owner_scale(&model, rh);
|
||||
Ok(model.region_resolved.get(&rh).map(|r| {
|
||||
inv * match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
@@ -201,10 +207,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let inv = 1.0 / owner_scale(&model, rh);
|
||||
Ok(match model.region_resolved.get(&rh) {
|
||||
Some(r) => (
|
||||
Value::Number(f64::from((r.left + r.right) * 0.5)),
|
||||
Value::Number(f64::from((r.bottom + r.top) * 0.5)),
|
||||
Value::Number(f64::from(inv * (r.left + r.right) * 0.5)),
|
||||
Value::Number(f64::from(inv * (r.bottom + r.top) * 0.5)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
@@ -273,3 +280,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The region's owner frame's effective scale (1 for an orphan) — the divisor that turns a
|
||||
/// resolved screen rect into the owner's units, the space every region getter answers in.
|
||||
fn owner_scale(model: &Model, rh: crate::widget::RegionHandle) -> f32 {
|
||||
model
|
||||
.arena
|
||||
.region(rh)
|
||||
.map(|r| crate::script::object::eff_scale(model, r.owner))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -588,3 +588,34 @@ fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
// that tells it apart from `StartMoving`, which raises "Frame %s is not movable".
|
||||
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
|
||||
}
|
||||
|
||||
/// A region's rect getters answer in its OWNER's units — screen ÷ the owner's effective scale —
|
||||
/// exactly as the frame getters do (decision 1985): a texture inside a frame scaled to 0.5 that
|
||||
/// covers the frame answers the frame's own width, not half of it.
|
||||
#[test]
|
||||
fn region_getters_answer_in_the_owners_units_under_scale() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"f = CreateFrame("Frame", "ScaledOwner") f:SetWidth(200) f:SetHeight(100)
|
||||
f:SetPoint("BOTTOMLEFT", 100, 50) f:SetScale(0.5)
|
||||
t = f:CreateTexture("ScaledTex") t:SetAllPoints(f)"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let (fl, fr, tl, tr, cx) = s
|
||||
.eval::<(f64, f64, f64, f64, f64)>(
|
||||
"local cx = t:GetCenter() return f:GetLeft(), f:GetRight(), t:GetLeft(), t:GetRight(), cx",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(fl - tl).abs() < 1e-3 && (fr - tr).abs() < 1e-3,
|
||||
"frame {fl}..{fr} vs region {tl}..{tr}"
|
||||
);
|
||||
assert!(
|
||||
(fr - fl - 200.0).abs() < 1e-3,
|
||||
"the owner's own width, {}",
|
||||
fr - fl
|
||||
);
|
||||
assert!((cx - (fl + fr) * 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
@@ -1262,6 +1262,7 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
|
||||
// comparison against our group's leader could ever express.
|
||||
s.set_party(PartyState {
|
||||
leader_guid: 0x9999,
|
||||
own_guid: 0,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_unit(
|
||||
@@ -1285,6 +1286,7 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
|
||||
// out-of-range member — the client holds no object, so leg 1 has nothing to read.
|
||||
s.set_party(PartyState {
|
||||
leader_guid: THEM,
|
||||
own_guid: 0,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_unit(
|
||||
|
||||
Reference in New Issue
Block a user