widget calls cost one index, and a reparent keeps the layout ledger
- a frame's __index is its kind's method table and the Region map's 19 shared verbs dispatch in Rust: a widget method call costs one table index instead of a Lua re-entry — the idle UI tick ~20 % lighter, client-wide (2310) - SetParent names the subtree it moved instead of giving the layout ledger up: a pooled map note's recycle no longer re-derives the whole interface, and a Questie quest-log change's worst frame halves, 166 → 83 ms (2314) - thirteen single-store windows — honor, talent wipe, the dialog verbs, scoreboard, battlemaster, battle map, book, mirror timers, minimap ping — register their handlers; 18 kinds leave the match (2313) - the addon-enable RE lands: DefaultState is a seeded byte, the node set is the character list, and a character the store has never seen inherits nothing — 2311's one wrong branch corrected (2316) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1075,6 +1075,7 @@ pub(crate) struct MinimapPlugin;
|
||||
|
||||
impl Plugin for MinimapPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
ping::register(app);
|
||||
app.add_observer(on_cvar);
|
||||
app.init_resource::<MinimapWidget>()
|
||||
.init_resource::<MinimapZoom>()
|
||||
|
||||
@@ -207,6 +207,26 @@ pub(super) fn drive_minimap_ping(
|
||||
);
|
||||
}
|
||||
|
||||
/// Register the ping's packet handler — called from [`super::MinimapPlugin`] (in the net handler
|
||||
/// table since 2313).
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use crate::net::NetHandlerApp;
|
||||
app.net_handler(
|
||||
benilla_protocol::SessionEventKind::MinimapPing,
|
||||
on_minimap_ping,
|
||||
);
|
||||
}
|
||||
|
||||
/// A group member pinged (decision 1596). The wire carries raw world floats and the relay is
|
||||
/// stateless in the reference too — we seat them as the pin and the minimap derives the rest.
|
||||
/// The server only relays a ping between people who are grouped, and a ping from another map
|
||||
/// would be dropped by the renderer's own map test anyway.
|
||||
fn on_minimap_ping(In(ev): In<benilla_protocol::SessionEvent>, mut ping: ResMut<MinimapPing>) {
|
||||
if let benilla_protocol::SessionEvent::MinimapPing { guid, x, y } = ev {
|
||||
ping.seat((x, y), guid);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -224,24 +224,11 @@ fn apply_unpeeled(
|
||||
mut world_states,
|
||||
social,
|
||||
mut logout,
|
||||
mut mirror_timers,
|
||||
mut pet_bar,
|
||||
mut ui_error_keys,
|
||||
mut page_texts,
|
||||
mut played_time_answer,
|
||||
mut talent_wipe,
|
||||
mut pet_unlearn,
|
||||
mut instance_boot,
|
||||
mut area_spirit,
|
||||
mut battlefield_queue,
|
||||
mut meeting_stone,
|
||||
mut battlefield_scoreboard,
|
||||
mut battlefield,
|
||||
mut tutorials,
|
||||
mut battlefield_positions,
|
||||
mut poi_marker,
|
||||
mut inspect_honor,
|
||||
mut ping,
|
||||
} = windows;
|
||||
let Session {
|
||||
mut teleports,
|
||||
@@ -619,13 +606,8 @@ fn apply_unpeeled(
|
||||
session::reputation_visible(list_id, &mut reputations)
|
||||
}
|
||||
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
|
||||
// The honor arc's two inbound messages (decision 1512).
|
||||
//
|
||||
// The inspect reply REPLACES whatever is held, including for a different player: the
|
||||
// reference's latch is a single slot, and a pane still showing the last target's
|
||||
// kills is the failure keeping the old one produces.
|
||||
SessionEvent::InspectHonorStats(stats) => inspect_honor.0 = Some(stats),
|
||||
// An honor award: the combat-log line (name-resolved, so it queues) and the floating
|
||||
// An honor award (decision 1512 — the arc's other inbound message, the inspect reply,
|
||||
// is `ui_honor`'s own handler): the combat-log line (name-resolved, so it queues) and the floating
|
||||
// number, which are two different surfaces of one packet and are both the reference's.
|
||||
// A DISHONORABLE kill arrives here too, carrying NEGATIVE honor — the floating text
|
||||
// takes it signed, because the shipped `COMBAT_TEXT_HONOR_GAINED` handler prefixes a
|
||||
@@ -642,49 +624,7 @@ fn apply_unpeeled(
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
// A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a
|
||||
// question — there is nothing to ask about, so nothing goes on screen (decision 1580;
|
||||
// `crate::ui_talent_wipe`'s header carries why the reference instead re-sends here).
|
||||
SessionEvent::TalentWipeConfirm { trainer, cost } => {
|
||||
if trainer == 0 {
|
||||
debug!("net: talent wipe refused (no talents to reset) — no dialog");
|
||||
} else {
|
||||
debug!("net: trainer {trainer:#x} asks to wipe talents for {cost} copper");
|
||||
talent_wipe.ask(trainer, cost);
|
||||
}
|
||||
}
|
||||
// The pet trainer's question (decision 1963) — the talent-wipe twin above; a zero
|
||||
// guid is the reference's own `ERR_TALENT_WIPE_ERROR` leg, carried over as observed.
|
||||
SessionEvent::PetUnlearnConfirm { trainer, cost } => {
|
||||
if trainer == 0 {
|
||||
debug!("net: pet unlearn refused (zero trainer) — no dialog");
|
||||
ui_error_keys
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_TALENT_WIPE_ERROR"));
|
||||
} else {
|
||||
debug!("net: trainer {trainer:#x} asks to unlearn the pet for {cost} copper");
|
||||
pet_unlearn.ask(trainer, cost);
|
||||
}
|
||||
}
|
||||
SessionEvent::RaidGroupOnly { delay_ms, reason } => {
|
||||
instance_boot.apply(delay_ms, reason, std::time::Instant::now());
|
||||
}
|
||||
SessionEvent::AreaSpiritHealerTime { healer, ms } => {
|
||||
area_spirit.on_time(healer, ms, std::time::Instant::now());
|
||||
}
|
||||
SessionEvent::BattlefieldStatus(status) => battlefield_queue.apply(status),
|
||||
SessionEvent::PvpLogData(data) => battlefield_scoreboard.apply(data),
|
||||
SessionEvent::BattlefieldList(list) => battlefield.apply_list(list),
|
||||
SessionEvent::GroupJoinedBattleground { result } => battlefield.apply_verdict(result),
|
||||
SessionEvent::BattlegroundPlayer { guid, joined } => {
|
||||
battlefield.apply_player(guid, joined);
|
||||
}
|
||||
SessionEvent::MeetingStoneSetQueue { area, status } => {
|
||||
meeting_stone.apply(area, status);
|
||||
}
|
||||
SessionEvent::MeetingStoneNotice(notice) => meeting_stone.apply_notice(notice),
|
||||
SessionEvent::TutorialFlags(bytes) => tutorials.apply_flags(&bytes),
|
||||
SessionEvent::BattlefieldPositions(packet) => battlefield_positions.apply(packet),
|
||||
SessionEvent::Proficiency {
|
||||
item_class,
|
||||
subclass_mask,
|
||||
@@ -949,30 +889,9 @@ fn apply_unpeeled(
|
||||
group::ready_check_request(&mut group, &mut ui_error_keys, &self_guid)
|
||||
}
|
||||
SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries),
|
||||
// A group member pinged (decision 1596). The wire carries raw world floats and the
|
||||
// relay is stateless in the reference too — we seat them as the pin and the minimap
|
||||
// derives the rest. `map` is the map we are standing on: the server only relays a ping
|
||||
// between people who are grouped, and a ping from another map would be dropped by the
|
||||
// renderer's own map test anyway.
|
||||
SessionEvent::MinimapPing { guid, x, y } => {
|
||||
ping.seat((x, y), guid);
|
||||
}
|
||||
SessionEvent::ReadyCheckAnswer { guid, ready } => {
|
||||
group.apply_ready_check_answer(guid, ready != 0)
|
||||
}
|
||||
// ── The mirror timers (decision 0874): breath / fatigue / feign-death. Pure queue
|
||||
// arms — every meaning (which bar, what colour, what caption, how fast it drains)
|
||||
// is resolved at the UI seam in `ui_mirror`, and the countdown itself is the
|
||||
// FrameXML's own OnUpdate integration ──────────────────────────────────────────────
|
||||
SessionEvent::MirrorTimerStart(start) => mirror_timers
|
||||
.0
|
||||
.push(crate::ui_mirror::MirrorTimerEdge::Start(start)),
|
||||
SessionEvent::MirrorTimerPause { kind, paused } => mirror_timers
|
||||
.0
|
||||
.push(crate::ui_mirror::MirrorTimerEdge::Pause { kind, paused }),
|
||||
SessionEvent::MirrorTimerStop { kind } => mirror_timers
|
||||
.0
|
||||
.push(crate::ui_mirror::MirrorTimerEdge::Stop { kind }),
|
||||
SessionEvent::LootResponse {
|
||||
guid,
|
||||
loot_type,
|
||||
@@ -1633,13 +1552,6 @@ fn apply_unpeeled(
|
||||
opcode,
|
||||
unparseable,
|
||||
} => session::packet_dropped(opcode, unparseable, &mut dropped),
|
||||
// The book-page cache (decision 1105) — one page per packet, the whole chain in
|
||||
// answer to the first ask; the reader repaints off it on the next feed.
|
||||
SessionEvent::PageText {
|
||||
page_id,
|
||||
text,
|
||||
next_page_id,
|
||||
} => page_texts.insert(page_id, text, next_page_id),
|
||||
SessionEvent::WorldStates { scope, states } => {
|
||||
world::world_states(scope, states, &mut world_states)
|
||||
}
|
||||
|
||||
@@ -171,47 +171,18 @@ pub(crate) struct WindowStores<'w> {
|
||||
/// The pending logout/quit (decision 0674): the server's response and cancel-ack land here,
|
||||
/// and `crate::ui_logout` turns them into the countdown dialog.
|
||||
pub logout: ResMut<'w, crate::ui_logout::LogoutState>,
|
||||
/// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges, drained
|
||||
/// into the FrameXML bars by `crate::ui_mirror`.
|
||||
pub mirror_timers: ResMut<'w, crate::ui_mirror::MirrorTimerFeed>,
|
||||
/// The pet action bar's server-authoritative state + its own cooldown store (decision 0982),
|
||||
/// replaced wholesale on every `SMSG_PET_SPELLS`.
|
||||
pub pet_bar: ResMut<'w, crate::ui_pet::PetBar>,
|
||||
/// The by-key red error queue the pet bar's refused-order feedback rides (the `DisplayError`
|
||||
/// route, resolved through the VM's own GlobalStrings by `ui_action::feed_actions`).
|
||||
pub ui_error_keys: ResMut<'w, crate::ui_action::UiErrorKeys>,
|
||||
/// The ask-once book-page cache (decision 1105) — every readable's text, keyed by `PageText`
|
||||
/// id; the reader session repaints off it.
|
||||
pub page_texts: ResMut<'w, crate::ui_item_text::PageTexts>,
|
||||
pub played_time_answer: ResMut<'w, crate::net::PlayedTimeAnswer>,
|
||||
/// The class trainer's pending respec question (decision 1580) — the inbound
|
||||
/// `MSG_TALENT_WIPE_CONFIRM` parks the trainer's guid + cost here and
|
||||
/// `crate::ui_talent_wipe` turns it into the CONFIRM_TALENT_WIPE dialog, whose Accept is the
|
||||
/// only thing that unlearns anything. The binder's twin.
|
||||
pub talent_wipe: ResMut<'w, crate::ui_talent_wipe::TalentWipeState>,
|
||||
/// The dialog engine's verbs (decision 1963): the pet trainer's latch, the instance-boot
|
||||
/// clock, the area spirit healer, the battleground queue and the meeting-stone queue — each
|
||||
/// a feed for a stock dialog.
|
||||
pub pet_unlearn: ResMut<'w, crate::ui_dialog_verbs::PetUnlearnState>,
|
||||
pub instance_boot: ResMut<'w, crate::ui_dialog_verbs::InstanceBoot>,
|
||||
pub area_spirit: ResMut<'w, crate::ui_dialog_verbs::AreaSpiritHealer>,
|
||||
pub battlefield_queue: ResMut<'w, crate::ui_dialog_verbs::BattlefieldQueue>,
|
||||
pub meeting_stone: ResMut<'w, crate::ui_dialog_verbs::MeetingStone>,
|
||||
pub battlefield_scoreboard: ResMut<'w, crate::ui_battlefield_score::BattlefieldScoreboard>,
|
||||
pub battlefield: ResMut<'w, crate::ui_battlefield::Battlefield>,
|
||||
pub tutorials: ResMut<'w, crate::tutorial::Tutorials>,
|
||||
pub battlefield_positions: ResMut<'w, crate::ui_battlefield_positions::BattlefieldPositions>,
|
||||
/// The guard's directions marker (`SMSG_GOSSIP_POI`) — the wire carries no map field, so
|
||||
/// "where you were standing when the guard told you" is the client's to remember
|
||||
/// (`crate::poi_marker`).
|
||||
pub poi_marker: ResMut<'w, crate::poi_marker::PoiMarker>,
|
||||
/// The inspect-honor reply (decision 1512) — `MSG_INSPECT_HONOR_STATS` is the only source of
|
||||
/// another player's honor numbers, so the reply parks here and `crate::ui_honor` pushes it
|
||||
/// into the pane and fires `INSPECT_HONOR_UPDATE`.
|
||||
pub inspect_honor: ResMut<'w, crate::ui_honor::InspectHonor>,
|
||||
/// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the world
|
||||
/// point here and the minimap renderer derives everything else from it.
|
||||
pub ping: ResMut<'w, crate::minimap::MinimapPing>,
|
||||
}
|
||||
|
||||
/// The action bar's family: the cast/cooldown state and every error queue the red line drains
|
||||
|
||||
@@ -432,7 +432,8 @@ mod tests {
|
||||
continue;
|
||||
};
|
||||
let kind = kind.trim().rsplit("::").next().unwrap_or_default();
|
||||
let handler = handler.trim();
|
||||
// rustfmt breaks a long call one argument a line, with a trailing comma.
|
||||
let handler = handler.trim().trim_end_matches(',').trim_end();
|
||||
seen += 1;
|
||||
if kind == "Disconnected" {
|
||||
continue;
|
||||
|
||||
@@ -358,10 +358,40 @@ fn reset_on_world_enter(
|
||||
let _ = commands.0.send(ClientCommand::BattlefieldStatusRequest);
|
||||
}
|
||||
|
||||
/// The battlemaster window's packet handlers (in the net handler table since 2313).
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::Battlefield;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handlers — called from [`super::BattlefieldPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::BattlefieldList, on_packet)
|
||||
.net_handler(K::GroupJoinedBattleground, on_packet)
|
||||
.net_handler(K::BattlegroundPlayer, on_packet);
|
||||
}
|
||||
|
||||
/// One handler for the three: each is a one-line fold into the same state.
|
||||
fn on_packet(In(ev): In<SessionEvent>, mut battlefield: ResMut<Battlefield>) {
|
||||
match ev {
|
||||
SessionEvent::BattlefieldList(list) => battlefield.apply_list(list),
|
||||
SessionEvent::GroupJoinedBattleground { result } => battlefield.apply_verdict(result),
|
||||
SessionEvent::BattlegroundPlayer { guid, joined } => {
|
||||
battlefield.apply_player(guid, joined)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BattlefieldPlugin;
|
||||
|
||||
impl Plugin for BattlefieldPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<Battlefield>().add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -223,10 +223,31 @@ fn reset_on_world_enter(
|
||||
state.last_request = None;
|
||||
}
|
||||
|
||||
/// The battlefield map's packet handler (in the net handler table since 2313).
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::BattlefieldPositions;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::BattlefieldPositionsPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::BattlefieldPositions, on_positions);
|
||||
}
|
||||
|
||||
fn on_positions(In(ev): In<SessionEvent>, mut positions: ResMut<BattlefieldPositions>) {
|
||||
if let SessionEvent::BattlefieldPositions(packet) = ev {
|
||||
positions.apply(packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BattlefieldPositionsPlugin;
|
||||
|
||||
impl Plugin for BattlefieldPositionsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<BattlefieldPositions>().add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -171,10 +171,31 @@ fn drain_battlefield_score(
|
||||
}
|
||||
}
|
||||
|
||||
/// The scoreboard's packet handler (in the net handler table since 2313).
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::BattlefieldScoreboard;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::BattlefieldScorePlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::PvpLogData, on_pvp_log_data);
|
||||
}
|
||||
|
||||
fn on_pvp_log_data(In(ev): In<SessionEvent>, mut board: ResMut<BattlefieldScoreboard>) {
|
||||
if let SessionEvent::PvpLogData(data) = ev {
|
||||
board.apply(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BattlefieldScorePlugin;
|
||||
|
||||
impl Plugin for BattlefieldScorePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<BattlefieldScoreboard>().add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -1078,10 +1078,79 @@ fn poll_area_spirit_healer(
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog verbs' packet handlers (in the net handler table since 2313) — each parks a
|
||||
/// question or a countdown on its own store for the feed to turn into a StaticPopup.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{AreaSpiritHealer, BattlefieldQueue, InstanceBoot, MeetingStone, PetUnlearnState};
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handlers — called from [`super::UiDialogVerbsPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::PetUnlearnConfirm, on_pet_unlearn_confirm)
|
||||
.net_handler(K::RaidGroupOnly, on_raid_group_only)
|
||||
.net_handler(K::AreaSpiritHealerTime, on_area_spirit_healer_time)
|
||||
.net_handler(K::BattlefieldStatus, on_battlefield_status)
|
||||
.net_handler(K::MeetingStoneSetQueue, on_meeting_stone)
|
||||
.net_handler(K::MeetingStoneNotice, on_meeting_stone);
|
||||
}
|
||||
|
||||
/// The pet trainer's question (decision 1963) — the talent wipe's twin
|
||||
/// ([`crate::ui_talent_wipe`]); a zero guid is the reference's own `ERR_TALENT_WIPE_ERROR`
|
||||
/// leg, carried over as observed.
|
||||
fn on_pet_unlearn_confirm(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut unlearn: ResMut<PetUnlearnState>,
|
||||
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
) {
|
||||
if let SessionEvent::PetUnlearnConfirm { trainer, cost } = ev {
|
||||
if trainer == 0 {
|
||||
debug!("net: pet unlearn refused (zero trainer) — no dialog");
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_TALENT_WIPE_ERROR"));
|
||||
} else {
|
||||
debug!("net: trainer {trainer:#x} asks to unlearn the pet for {cost} copper");
|
||||
unlearn.ask(trainer, cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_raid_group_only(In(ev): In<SessionEvent>, mut boot: ResMut<InstanceBoot>) {
|
||||
if let SessionEvent::RaidGroupOnly { delay_ms, reason } = ev {
|
||||
boot.apply(delay_ms, reason, std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn on_area_spirit_healer_time(In(ev): In<SessionEvent>, mut spirit: ResMut<AreaSpiritHealer>) {
|
||||
if let SessionEvent::AreaSpiritHealerTime { healer, ms } = ev {
|
||||
spirit.on_time(healer, ms, std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn on_battlefield_status(In(ev): In<SessionEvent>, mut queue: ResMut<BattlefieldQueue>) {
|
||||
if let SessionEvent::BattlefieldStatus(status) = ev {
|
||||
queue.apply(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_meeting_stone(In(ev): In<SessionEvent>, mut stone: ResMut<MeetingStone>) {
|
||||
match ev {
|
||||
SessionEvent::MeetingStoneSetQueue { area, status } => stone.apply(area, status),
|
||||
SessionEvent::MeetingStoneNotice(notice) => stone.apply_notice(notice),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiDialogVerbsPlugin;
|
||||
|
||||
impl Plugin for UiDialogVerbsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<PetUnlearnState>()
|
||||
.init_resource::<InstanceBoot>()
|
||||
.init_resource::<AreaSpiritHealer>()
|
||||
|
||||
@@ -84,10 +84,36 @@ struct HonorFeedMemo {
|
||||
last_inspect: Option<u64>,
|
||||
}
|
||||
|
||||
/// The honor pane's packet handler (decision 1512; in the net handler table since 2313). The
|
||||
/// honor arc's other inbound message, the award (`PvpCredit`), is a chat line and a floating
|
||||
/// number and stays with the chat family.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::InspectHonor;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::UiHonorPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::InspectHonorStats, on_inspect_stats);
|
||||
}
|
||||
|
||||
/// The inspect reply REPLACES whatever is held, including for a different player: the
|
||||
/// reference's latch is a single slot, and a pane still showing the last target's kills is
|
||||
/// the failure keeping the old one produces.
|
||||
fn on_inspect_stats(In(ev): In<SessionEvent>, mut inspect: ResMut<InspectHonor>) {
|
||||
if let SessionEvent::InspectHonorStats(stats) = ev {
|
||||
inspect.0 = Some(stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiHonorPlugin;
|
||||
|
||||
impl Plugin for UiHonorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<InspectHonor>()
|
||||
.init_resource::<HonorFeedState>()
|
||||
.add_systems(Update, feed_honor.in_set(UiFeed));
|
||||
|
||||
@@ -178,10 +178,38 @@ impl ItemTextOpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The book reader's packet handler (decision 1105; in the net handler table since 2313).
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::PageTexts;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::UiItemTextPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::PageText, on_page_text);
|
||||
}
|
||||
|
||||
/// The book-page cache — one page per packet, the whole chain in answer to the first ask;
|
||||
/// the reader repaints off it on the next feed.
|
||||
fn on_page_text(In(ev): In<SessionEvent>, mut pages: ResMut<PageTexts>) {
|
||||
if let SessionEvent::PageText {
|
||||
page_id,
|
||||
text,
|
||||
next_page_id,
|
||||
} = ev
|
||||
{
|
||||
pages.insert(page_id, text, next_page_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiItemTextPlugin;
|
||||
|
||||
impl Plugin for UiItemTextPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
crate::query_cache::register::<PageTexts>(app);
|
||||
app.init_resource::<ItemTextOpen>()
|
||||
.init_resource::<PageTexts>()
|
||||
|
||||
@@ -191,10 +191,43 @@ fn feed_mirror_timers(
|
||||
|
||||
/// The mirror-timer UI seam: the queue + its drain, ordered like the cast bar's — before the VM
|
||||
/// ticks, so an edge and its first OnUpdate land on the same frame.
|
||||
/// The mirror timers' packet handlers (decision 0874; in the net handler table since 2313):
|
||||
/// breath / fatigue / feign-death. Pure queue handlers — every meaning (which bar, what colour,
|
||||
/// what caption, how fast it drains) is resolved at the UI seam in this module, and the
|
||||
/// countdown itself is the FrameXML's own OnUpdate integration.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{MirrorTimerEdge, MirrorTimerFeed};
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handlers — called from [`super::UiMirrorPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::MirrorTimerStart, on_edge)
|
||||
.net_handler(K::MirrorTimerPause, on_edge)
|
||||
.net_handler(K::MirrorTimerStop, on_edge);
|
||||
}
|
||||
|
||||
fn on_edge(In(ev): In<SessionEvent>, mut feed: ResMut<MirrorTimerFeed>) {
|
||||
let edge = match ev {
|
||||
SessionEvent::MirrorTimerStart(start) => MirrorTimerEdge::Start(start),
|
||||
SessionEvent::MirrorTimerPause { kind, paused } => {
|
||||
MirrorTimerEdge::Pause { kind, paused }
|
||||
}
|
||||
SessionEvent::MirrorTimerStop { kind } => MirrorTimerEdge::Stop { kind },
|
||||
_ => return,
|
||||
};
|
||||
feed.0.push(edge);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiMirrorPlugin;
|
||||
|
||||
impl Plugin for UiMirrorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<MirrorTimerFeed>()
|
||||
.add_systems(Update, feed_mirror_timers.in_set(UnitFeed));
|
||||
}
|
||||
|
||||
@@ -855,6 +855,11 @@ pub(crate) fn installed_rows() -> Vec<InstalledAddOn> {
|
||||
/// `ds:0xbe1bd8`), which `AddOnList_LoadCharacter 0x51ebe0` fills one node per character at
|
||||
/// char-list population (wow-5875-re `system/ui/scratch/addon-enable-store.md` §1/§5).
|
||||
///
|
||||
/// That the node set is the **char-list, in wire order, rebuilt whole** is verified rather than
|
||||
/// assumed (wow-re `addon-defaultstate-and-node-set.md`, decision 2316): the per-record callback
|
||||
/// `0x472300` is handed to an enumerator that walks every `SMSG_CHAR_ENUM` record with no filter
|
||||
/// and no early-out, and its driver destroys every existing node first (`0x51f0b0(NULL)`).
|
||||
///
|
||||
/// **A node with no file is EMPTY, not absent**, and the difference is the whole point: an empty
|
||||
/// node contributes no opinion to the aggregate, but it is still a character whose enable bit has
|
||||
/// to be answered — and the reference answers it from the *other* characters, never with a bare
|
||||
@@ -917,25 +922,37 @@ impl EnableStore {
|
||||
}
|
||||
|
||||
/// **The bit a character actually gets** — `0x51e470(addon, character, useDefault = 1)`
|
||||
/// lowered to a bool (a single-character query returns only 0 or 2, §4).
|
||||
/// lowered to a bool (a single-character query returns only 0 or 2).
|
||||
///
|
||||
/// Their own explicit row when their file has one; otherwise the aggregate above, and where
|
||||
/// that is undecided, the addon's `## DefaultState`. `None` is the no-character case (nobody
|
||||
/// picked yet), which is the aggregate alone.
|
||||
/// Three cases, and the reference distinguishes the last two (decision 2316, which corrects
|
||||
/// 2311's reading of them as one):
|
||||
///
|
||||
/// * **their file has an explicit row** → that row, and nothing else is consulted;
|
||||
/// * **they have a node but no row for this addon** → [`Self::aggregate`], the explicit-only
|
||||
/// fold over the character list (`0x51e5f0`'s self-recursion), falling to `## DefaultState`
|
||||
/// only where that is undecided;
|
||||
/// * **no node carries their name at all** → `## DefaultState`, *not* the aggregate. The walk
|
||||
/// compares the name at every node and advances past each mismatch
|
||||
/// (`0x51e55a jne 0x51e611`, bypassing the stop-check at `0x51e60a`), so it exhausts with
|
||||
/// `total == 0` and takes that epilogue. A character the list does not carry inherits
|
||||
/// nothing. `None` — nobody picked yet — is this case too.
|
||||
///
|
||||
/// That third arm is unreachable from our own callers: every panel column is a node, and
|
||||
/// [`store_nodes`] seats the loading character. It is written faithfully anyway, because the
|
||||
/// next caller would otherwise find the wrong branch sitting here and have no way to know.
|
||||
pub(crate) fn enabled_for(
|
||||
&self,
|
||||
addon: &str,
|
||||
default_state: bool,
|
||||
character: Option<&str>,
|
||||
) -> bool {
|
||||
let key = addon.to_ascii_lowercase();
|
||||
if let Some(explicit) = character
|
||||
.and_then(|c| self.node(c))
|
||||
.and_then(|h| h.get(&key))
|
||||
{
|
||||
return *explicit;
|
||||
let Some(node) = character.and_then(|c| self.node(c)) else {
|
||||
return default_state;
|
||||
};
|
||||
match node.get(&addon.to_ascii_lowercase()) {
|
||||
Some(&explicit) => explicit,
|
||||
None => self.aggregate(addon).unwrap_or(default_state),
|
||||
}
|
||||
self.aggregate(addon).unwrap_or(default_state)
|
||||
}
|
||||
|
||||
fn node(&self, character: &str) -> Option<&HashMap<String, bool>> {
|
||||
@@ -2964,6 +2981,64 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(home.parent().unwrap());
|
||||
}
|
||||
|
||||
/// **A name the character list does not carry inherits nothing** — the third arm of
|
||||
/// `0x51e470(addon, character, useDefault = 1)`, and the one 2311 got wrong (decision 2316).
|
||||
///
|
||||
/// 2311 read "no explicit row" and "no node" as one case and sent both to the aggregate. The
|
||||
/// reference splits them: the walk compares the name at every node and advances past each
|
||||
/// mismatch (`0x51e55a jne 0x51e611`, bypassing the stop-check), so an unknown name exhausts
|
||||
/// the list with `total == 0` and takes the `DefaultState ? 2 : 0` epilogue. Only a character
|
||||
/// that *has* a node inherits.
|
||||
///
|
||||
/// Unreachable from our own callers — every panel column is a node, and `store_nodes` seats
|
||||
/// the loading character — so this is the falsifier standing in for the caller that does not
|
||||
/// exist yet. `Known`, who has a node and no row, is the control that keeps the two arms
|
||||
/// honestly distinguishable: both addons are unanimous, so an implementation that collapsed
|
||||
/// the cases would answer `false` for `Stranger` too.
|
||||
#[test]
|
||||
fn an_unknown_character_takes_the_manifest_default_not_the_aggregate() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
|
||||
let (home, _h) = hermetic_root("unknown-char");
|
||||
// One unanimous `disabled`, and one addon whose manifest ships `DefaultState: disabled`
|
||||
// so the epilogue's two outcomes are told apart rather than both reading `true`.
|
||||
write_addon(&home, "Shunned", "## Interface: 11200\n", &[]);
|
||||
write_addon(
|
||||
&home,
|
||||
"OptIn",
|
||||
"## Interface: 11200\n## DefaultState: disabled\n",
|
||||
&[],
|
||||
);
|
||||
for who in ["Onemage", "Onerogue"] {
|
||||
let id = ("Realm".to_string(), who.to_string());
|
||||
write_enable_state(
|
||||
Some(&id),
|
||||
&[("Shunned".into(), false), ("OptIn".into(), true)],
|
||||
);
|
||||
}
|
||||
let roster = [
|
||||
"Onemage".to_string(),
|
||||
"Onerogue".to_string(),
|
||||
"Known".to_string(),
|
||||
];
|
||||
let store = EnableStore::load("Realm", &roster);
|
||||
|
||||
// `Known` has a node and no rows: both addons inherit their unanimous aggregate.
|
||||
assert!(!store.enabled_for("Shunned", true, Some("Known")));
|
||||
assert!(store.enabled_for("OptIn", false, Some("Known")));
|
||||
|
||||
// `Stranger` is on no node: neither aggregate reaches them — each addon answers with its
|
||||
// own manifest default, which is the opposite verdict in both cases.
|
||||
assert!(store.enabled_for("Shunned", true, Some("Stranger")));
|
||||
assert!(!store.enabled_for("OptIn", false, Some("Stranger")));
|
||||
// …and `None` (nobody picked yet) is the same epilogue.
|
||||
assert!(store.enabled_for("Shunned", true, None));
|
||||
assert!(!store.enabled_for("OptIn", false, None));
|
||||
let _ = std::fs::remove_dir_all(home.parent().unwrap());
|
||||
}
|
||||
|
||||
/// Enable state survives the session: `DisableAddOn` from Lua is written back in the
|
||||
/// reference's own `AddOns.txt` format, and read back as a disable next time.
|
||||
#[test]
|
||||
|
||||
@@ -196,10 +196,39 @@ fn drain_talent_wipe(
|
||||
}
|
||||
|
||||
/// The respec flow: the range guard, the dialog's feed, and its answer.
|
||||
/// The talent-wipe question's packet handler (decision 1580; in the net handler table since 2313).
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::TalentWipeState;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::UiTalentWipePlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::TalentWipeConfirm, on_confirm);
|
||||
}
|
||||
|
||||
/// A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a question —
|
||||
/// there is nothing to ask about, so nothing goes on screen (this module's header carries
|
||||
/// why the reference instead re-sends here).
|
||||
fn on_confirm(In(ev): In<SessionEvent>, mut wipe: ResMut<TalentWipeState>) {
|
||||
if let SessionEvent::TalentWipeConfirm { trainer, cost } = ev {
|
||||
if trainer == 0 {
|
||||
debug!("net: talent wipe refused (no talents to reset) — no dialog");
|
||||
} else {
|
||||
debug!("net: trainer {trainer:#x} asks to wipe talents for {cost} copper");
|
||||
wipe.ask(trainer, cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UiTalentWipePlugin;
|
||||
|
||||
impl Plugin for UiTalentWipePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
app.init_resource::<TalentWipeState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -550,14 +550,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
lua.set_named_registry_value(REG_FONT_METHODS, m)?;
|
||||
|
||||
let meta = lua.create_table()?;
|
||||
let index = lua.create_function(|lua, (_this, key): (Table, Value)| {
|
||||
let methods: Table = lua.named_registry_value(REG_FONT_METHODS)?;
|
||||
methods.get::<Value>(key)
|
||||
})?;
|
||||
meta.set("__index", index)?;
|
||||
// **`__index` is the method TABLE, not a dispatcher function** (decision 2310). A Rust
|
||||
// `__index` turns every `fo.GetFont` — a plain table index in the source — into a Lua→Rust→Lua
|
||||
// round trip plus a named-registry string lookup; measured at ~200 ns against ~9 ns for the
|
||||
// table form, on a path every widget call in the client begins with. The table is mutated in
|
||||
// place by nothing after this point, so pointing at it cannot go stale.
|
||||
meta.set("__index", m.clone())?;
|
||||
lua.set_named_registry_value(REG_FONT_METHODS, m)?;
|
||||
lua.set_named_registry_value(REG_FONT_META, meta)?;
|
||||
|
||||
lua.globals()
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
//! answers 2 in this VM. That matters for 1751: a stock file using 5.0 varargs needs nothing from
|
||||
//! this module, and the rewrite was a precaution against a problem we did not have.
|
||||
//!
|
||||
//! **And the other half of that, which costs an hour to rediscover: `...` is a DECLARATION token
|
||||
//! here and nothing else.** This VM parses `function(x, ...)`, and then rejects every *use* of
|
||||
//! `...` as an expression — `return ...`, `f(x, ...)`, and `local a = ...` at chunk level — with
|
||||
//! `unexpected symbol near '...'`. `arg` and `unpack(arg)` are the only forwarding forms
|
||||
//! ([`vararg_is_a_declaration_only`] pins all six). That is 5.0's own shape and exactly right for
|
||||
//! the content we run, but it also binds anything WE write in Lua: an engine-side helper cannot
|
||||
//! forward an unknown argument list without a table, so a verb whose arity matters (`GetPoint()`
|
||||
//! vs `GetPoint(nil)`, `SetAllPoints()` vs `SetAllPoints(nil)`) has to stay on the Rust side.
|
||||
//! Decision 2310 is where that bit — a Lua dispatcher for the Region method map would have been
|
||||
//! ~4x cheaper than the Rust one and cannot be written in this dialect.
|
||||
//!
|
||||
//! ## The one known divergence, stated rather than hidden
|
||||
//!
|
||||
//! `table.insert`/`table.remove` stay on 5.1's `#t` border rather than consulting `getn`. **This
|
||||
@@ -780,6 +791,28 @@ mod tests {
|
||||
assert_eq!(s.eval::<f64>("return floor(2.7)").unwrap(), 2.0);
|
||||
}
|
||||
|
||||
/// **`...` declares a vararg function here and can never be read.** The six forms, together,
|
||||
/// because the split between them is the whole surprise: the declaration parses, every use
|
||||
/// does not, and `arg` carries what `...` cannot (the module header's second vararg note).
|
||||
#[test]
|
||||
fn vararg_is_a_declaration_only() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let parses = |src: &str| s.run(src).is_ok();
|
||||
// Declaration, and the 5.0 way to read what it captured.
|
||||
assert!(parses("return function(x, ...) return x end"));
|
||||
assert!(parses("return function(x, ...) return arg.n end"));
|
||||
assert!(parses(
|
||||
"local f = tostring return function(x, ...) return f(x, unpack(arg)) end"
|
||||
));
|
||||
// Every use of `...` as an expression — including at chunk level, where 5.1 proper allows
|
||||
// it and this build does not.
|
||||
assert!(!parses("return function(x, ...) return ... end"));
|
||||
assert!(!parses(
|
||||
"local f = tostring return function(x, ...) return f(x, ...) end"
|
||||
));
|
||||
assert!(!parses("local a = ... return a"));
|
||||
}
|
||||
|
||||
/// The three compat globals the reference has and we did not.
|
||||
#[test]
|
||||
fn sort_foreach_and_foreachi_are_bare_globals_like_the_reference() {
|
||||
|
||||
@@ -2386,6 +2386,67 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// A **REPARENT** that names its nodes: `SetParent` moved the effective scale of every frame
|
||||
/// in the subtree rooted at `root`, and moved nothing else the layout graph is made of
|
||||
/// (decision 2314, extending 1388).
|
||||
///
|
||||
/// ## Why a reparent is a value-only write here
|
||||
///
|
||||
/// `LayoutInput.scale` is the only layout input a reparent touches: the arena recomputes the
|
||||
/// subtree's `effective_scale` and the derive syncs it per frame. Nothing else moves —
|
||||
/// **an anchor's target is an id resolved at `SetPoint` time** ([`super::object::anchor_args::
|
||||
/// resolve_rel_target`]), not a live "my parent" link, so no edge is retargeted; no node is
|
||||
/// born or dies, so the roster is unchanged; and visibility, strata and level are not layout
|
||||
/// inputs at all (the derive walks liveness, not `effective_visible`). A region carries no
|
||||
/// scale of its own — it reads its owner frame's — so naming the frames is enough: the dirty
|
||||
/// closure pulls the regions that read them.
|
||||
///
|
||||
/// ## Why it is worth naming a whole subtree
|
||||
///
|
||||
/// `SetParent` used to take [`Self::touch_layout`], on the reading that a reparent is
|
||||
/// human-rate. **It is not.** A pooled widget's recycle is a reparent, and an addon's map-note
|
||||
/// pool recycles hundreds per redraw: with the director's addon set, every quest-log change
|
||||
/// cost two frames of ~160 ms, of which `WOW_LAYOUT_PROF` attributed **128 ms to 58 full
|
||||
/// graph derivations** — 5,267 frames and 16,124 anchored regions walked and hashed, ~3.2 ms
|
||||
/// each, and `WOW_LAYOUT_DERIVE_TRACE` put all fourteen sampled give-ups on this one call.
|
||||
/// A subtree is a handful of nodes; the roster is twenty thousand.
|
||||
///
|
||||
/// Falls back to the conservative touch the moment a node of the subtree is not a node of the
|
||||
/// cached graph — [`Self::touch_layout_frame`]'s own rule, and the same "worst case is a
|
||||
/// derivation" safety every precise touch has.
|
||||
pub(crate) fn touch_layout_reparent(&mut self, root: FrameHandle) {
|
||||
// The walk first, then the writing: `touch_layout_frame` takes `&mut self` and the arena
|
||||
// walk holds `&self`.
|
||||
let mut subtree = vec![root];
|
||||
let mut at = 0;
|
||||
while at < subtree.len() {
|
||||
let h = subtree[at];
|
||||
at += 1;
|
||||
if let Some(f) = self.arena.frame(h) {
|
||||
subtree.extend(f.children.iter().copied());
|
||||
}
|
||||
}
|
||||
// **The write itself, not only its name.** `LayoutInput.scale` was synced from the arena
|
||||
// inside [`super::UiScript::resolve_layout`]'s DERIVATION — so a pass that skips the
|
||||
// derivation never learned the new scale, and a named-but-unwritten node is the one shape
|
||||
// that ships a stale rect. `WOW_LAYOUT_VERIFY` caught exactly that on the first run of
|
||||
// `setparent::a_reparent_under_a_scale_change_moves_the_childs_rect`; naming is a claim
|
||||
// about what moved, and this is what makes the claim true.
|
||||
let Model {
|
||||
arena,
|
||||
layout_inputs,
|
||||
..
|
||||
} = self;
|
||||
for &h in &subtree {
|
||||
if let Some(f) = arena.frame(h) {
|
||||
layout_inputs.entry(h).or_default().scale = f.effective_scale;
|
||||
}
|
||||
}
|
||||
for h in subtree {
|
||||
self.touch_layout_frame(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// An anchor RETARGET that names its node: the write moved node `h`'s (or `rh`'s) set of
|
||||
/// anchor TARGETS, and hands over both target lists so the cached graph's edges can be
|
||||
/// re-pointed instead of thrown away (decision 1625, extending 1388).
|
||||
|
||||
@@ -79,18 +79,22 @@ pub(super) fn frame_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
|
||||
}
|
||||
let t = lua.create_table()?;
|
||||
t.raw_set(0, Value::LightUserData(id_to_lud(id)))?;
|
||||
let meta: Table = lua.named_registry_value(REG_FRAME_META)?;
|
||||
t.set_metatable(Some(meta))?;
|
||||
wrappers.set(id, t.clone())?;
|
||||
|
||||
let name: Option<String> = {
|
||||
// The frame's kind and name in one borrow. **The KIND picks the metatable, here rather than
|
||||
// per lookup** — the region side's shape since its leaves split, and decision 2310's whole
|
||||
// change on this side: a wrapper is built once per frame, so resolving the class chain at
|
||||
// construction costs nothing, while resolving it inside `__index` put a Rust call, a model
|
||||
// borrow and a registry lookup in front of EVERY widget method access in the client. A frame's
|
||||
// kind never changes and `id_to_frame` never loses an entry, so the choice cannot go stale.
|
||||
let (kind, name): (Option<FrameKind>, Option<String>) = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
let frame = model
|
||||
.id_to_frame
|
||||
.get(&id)
|
||||
.and_then(|h| model.arena.frame(*h))
|
||||
.and_then(|f| f.name.clone())
|
||||
.and_then(|h| model.arena.frame(*h));
|
||||
(frame.map(|f| f.kind), frame.and_then(|f| f.name.clone()))
|
||||
};
|
||||
t.set_metatable(Some(frame_meta_for(lua, kind)?))?;
|
||||
wrappers.set(id, t.clone())?;
|
||||
if let Some(name) = name {
|
||||
publish_global(lua, &name, &t)?;
|
||||
}
|
||||
@@ -409,6 +413,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.set_named_registry_value(REG_WRAPPERS, lua.create_table()?)?;
|
||||
lua.set_named_registry_value(REG_SCRIPTS, lua.create_table()?)?;
|
||||
|
||||
// The Region-map arm collection, opened before either method table is built and closed by
|
||||
// `region_map::install` below (its [`super::region_map::Arms`] doc says why it is app_data).
|
||||
super::region_map::open_arms(lua);
|
||||
install_frame_methods(lua)?;
|
||||
super::region::install(lua)?;
|
||||
super::statusbar::install(lua)?;
|
||||
@@ -418,23 +425,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// implementation each, so a method pulled off a frame works on a texture (decision 1501).
|
||||
super::region_map::install(lua)?;
|
||||
|
||||
// Shared frame metatable: __index is a Rust dispatcher over the frame method table (RF-0023),
|
||||
// checking the frame's *kind-specific* method table first. Per-kind resolution matters beyond
|
||||
// correctness: addons duck-type widgets (`if frame.SetValue then …`), so a plain frame must
|
||||
// resolve `SetValue` to nil — one shared table would make every frame quack like every widget.
|
||||
let frame_meta = lua.create_table()?;
|
||||
let frame_index = lua.create_function(|lua, (this, key): (Table, Value)| {
|
||||
for reg in kind_method_registries(lua, &this) {
|
||||
let methods: Table = lua.named_registry_value(reg)?;
|
||||
let v = methods.get::<Value>(key.clone())?;
|
||||
if !v.is_nil() {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
let methods: Table = lua.named_registry_value(REG_FRAME_METHODS)?;
|
||||
methods.get::<Value>(key)
|
||||
})?;
|
||||
frame_meta.set("__index", frame_index)?;
|
||||
// The per-kind metatable cache ([`frame_meta_for`]) and the base metatable in it: the one a
|
||||
// plain `Frame` wears, whose `__index` is the shared frame method table itself.
|
||||
lua.set_named_registry_value(REG_KIND_METAS, lua.create_table()?)?;
|
||||
let frame_meta = frame_meta_for(lua, None)?;
|
||||
lua.set_named_registry_value(REG_FRAME_META, frame_meta.clone())?;
|
||||
// RF-0023 publishes the shared metatable as _G["__framescript_meta"].
|
||||
lua.globals().set("__framescript_meta", frame_meta)?;
|
||||
@@ -536,17 +530,71 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The registry keys of a frame's kind-specific method tables, in resolution order — the `__index`
|
||||
/// dispatcher walks them before the shared table, mirroring the client's class chain (CheckButton
|
||||
/// resolves through Button's map, RF-28). Empty for plain kinds (and for anything that isn't a live
|
||||
/// frame, e.g. a region wrapper passing through).
|
||||
fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
|
||||
let Ok(id) = decode_id(this) else { return &[] };
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(h) = model.id_to_frame.get(&id) else {
|
||||
return &[];
|
||||
};
|
||||
match model.arena.frame(*h).map(|f| f.kind) {
|
||||
/// The registry table of per-kind frame metatables, keyed by the head of the kind's method chain
|
||||
/// (`""` for a kind with none). Lua-side, like every other root here — the MAXCSTACK discipline.
|
||||
const REG_KIND_METAS: &str = "__benilla_frame_meta_by_kind";
|
||||
|
||||
/// The metatable a frame of `kind` wears — built once per kind, cached in [`REG_KIND_METAS`].
|
||||
///
|
||||
/// ## `__index` is a TABLE, and that is the whole point (decision 2310)
|
||||
///
|
||||
/// This used to be one shared metatable whose `__index` was a **Rust function** that resolved the
|
||||
/// receiver's kind and walked its registries per lookup. Every `frame:SetPoint(...)` — every plain
|
||||
/// `frame.Foo` read, including the `if frame.SetValue then` duck-type probe addons open with —
|
||||
/// therefore crossed into Rust, took a model borrow, resolved a handle and did one to three
|
||||
/// named-registry string lookups. Measured in a release build: **217 ns for a plain Frame, 320 ns
|
||||
/// for a Button, against 8.7 ns for a plain Lua table `__index`.** A live sample of a quest-accept
|
||||
/// spike with the director's addon set put ~20 % of the whole UI tick inside that metamethod
|
||||
/// before any method body ran — Questie redraws its map notes with ~19 widget calls per cluster
|
||||
/// over hundreds of clusters, and pays the tax on every one.
|
||||
///
|
||||
/// A table costs one `luaH_get` inside `luaV_gettable`, in C, with no call at all.
|
||||
///
|
||||
/// ## The chain is the reference's own, expressed as metatables
|
||||
///
|
||||
/// 1.12's widget classes are not a Lua metatable chain — each class owns a flat method map and its
|
||||
/// lookup tail-calls exactly one base's lookup ([`super::region_map`]'s header has the tables). But
|
||||
/// `luaV_gettable` follows a table `__index` iteratively, so a chain of metatables IS that probe
|
||||
/// sequence: `CheckButton`'s map, miss, `Button`'s map, miss, `Frame`'s map. So rather than merging
|
||||
/// each kind's methods into one flat copy, this links the existing tables — no copies to go stale
|
||||
/// when a method table is written after the fact, and the per-kind surface stays exactly what
|
||||
/// [`kind_method_registries`] declares. Duck typing is preserved by construction: a plain Frame's
|
||||
/// chain never reaches `StatusBar`'s map, so `frame.SetValue` is still nil.
|
||||
fn frame_meta_for(lua: &Lua, kind: Option<FrameKind>) -> mlua::Result<Table> {
|
||||
let chain = kind_method_registries(kind);
|
||||
let key = chain.first().copied().unwrap_or("");
|
||||
let metas: Table = lua.named_registry_value(REG_KIND_METAS)?;
|
||||
if let Value::Table(meta) = metas.raw_get::<Value>(key)? {
|
||||
return Ok(meta);
|
||||
}
|
||||
// Link `own -> base -> ... -> Frame`, idempotently: a base table shared by several kinds (every
|
||||
// button kind reaches `Button`'s) is simply re-pointed at the same place.
|
||||
let frame_methods: Table = lua.named_registry_value(REG_FRAME_METHODS)?;
|
||||
let mut below = frame_methods.clone();
|
||||
for reg in chain.iter().rev() {
|
||||
let own: Table = lua.named_registry_value(reg)?;
|
||||
let link = lua.create_table()?;
|
||||
link.set("__index", below)?;
|
||||
own.set_metatable(Some(link))?;
|
||||
below = own;
|
||||
}
|
||||
let meta = lua.create_table()?;
|
||||
meta.set("__index", below)?;
|
||||
metas.raw_set(key, meta.clone())?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// The registry keys of a frame's kind-specific method tables, **in resolution order** — the
|
||||
/// client's own class chain (CheckButton resolves through Button's map, RF-28). Empty for a plain
|
||||
/// kind, and for a wrapper whose frame is not live.
|
||||
///
|
||||
/// Every chain here is *its own table followed by its base's whole chain*, which is what lets
|
||||
/// [`frame_meta_for`] realize it as a metatable chain instead of a search: `CheckButton`'s table
|
||||
/// gets `Button`'s as its `__index`, `Button`'s gets `Frame`'s, and one `luaV_gettable` walks the
|
||||
/// lot in C. Keep that property when adding a kind — a chain that is not a suffix of its base's
|
||||
/// cannot be expressed this way.
|
||||
fn kind_method_registries(kind: Option<FrameKind>) -> &'static [&'static str] {
|
||||
match kind {
|
||||
Some(FrameKind::StatusBar) => &[super::statusbar::REG_STATUSBAR_METHODS],
|
||||
Some(FrameKind::EditBox) => &[super::editbox::REG_EDITBOX_METHODS],
|
||||
Some(FrameKind::ScrollingMessageFrame) => {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::script::region_map::{set_shared, Side};
|
||||
|
||||
use crate::order::Strata;
|
||||
use crate::script::{event, Backdrop, Insets, Model};
|
||||
use crate::widget::FrameKind;
|
||||
@@ -51,20 +53,17 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
// Identity / hierarchy
|
||||
m.set(
|
||||
"GetName",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let name = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model.arena.frame(h).and_then(|f| f.name.clone())
|
||||
};
|
||||
match name {
|
||||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetName", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let name = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model.arena.frame(h).and_then(|f| f.name.clone())
|
||||
};
|
||||
match name {
|
||||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?;
|
||||
// GetID/SetID — the app-meaning-free numeric label (XML `id=`, the client's `+0xb4`): a
|
||||
// dropdown row's list position, a tab index. Default 0 (see `Frame::wow_id`).
|
||||
m.set(
|
||||
@@ -148,19 +147,19 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
.map_or(&["Frame", "Region"][..], |f| type_chain(f.kind)))
|
||||
}
|
||||
|
||||
m.set(
|
||||
"GetObjectType",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetObjectType", |lua, this: Table| {
|
||||
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
|
||||
})?;
|
||||
|
||||
// Same four traps as the region twin (see `script::region`): case-insensitive whole-string,
|
||||
// number 1 on a hit and nil on a miss, exactly one value either way, and a non-string
|
||||
// non-number argument raises the reference's own `Usage:` text.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"IsObjectType",
|
||||
lua.create_function(|lua, (this, want): (Table, Value)| {
|
||||
|lua, (this, want): (Table, Value)| {
|
||||
let chain = chain_of(lua, &this)?;
|
||||
let want = match &want {
|
||||
Value::String(s) => s.to_str()?.to_string(),
|
||||
@@ -184,7 +183,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
} else {
|
||||
Value::Nil
|
||||
})
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// ── GetFrameType / IsFrameType: the FRAME-side spellings of the pair above (decision 2106) ──
|
||||
@@ -255,24 +254,21 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetParent",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let parent_id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.parent)
|
||||
.map(|p| model.frame_id(p))
|
||||
};
|
||||
match parent_id {
|
||||
Some(pid) => Ok(Value::Table(frame_wrapper(lua, pid)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetParent", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let parent_id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.parent)
|
||||
.map(|p| model.frame_id(p))
|
||||
};
|
||||
match parent_id {
|
||||
Some(pid) => Ok(Value::Table(frame_wrapper(lua, pid)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?;
|
||||
// ── The four structure queries: GetChildren / GetNumChildren / GetRegions / GetNumRegions ──
|
||||
//
|
||||
// Registered bindings in 1.12 (`GetNumRegions 0x773e60`, `GetRegions 0x773f60`,
|
||||
@@ -378,9 +374,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// reference's hide→show round-trip does — `fire_visibility_changes` reads each frame's live
|
||||
// state for direction, so hide and show must be fired from their own phase, never one
|
||||
// combined list.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"SetParent",
|
||||
lua.create_function(|lua, args: mlua::MultiValue| {
|
||||
|lua, args: mlua::MultiValue| {
|
||||
let mut it = args.into_iter();
|
||||
let this = match it.next() {
|
||||
Some(Value::Table(t)) => t,
|
||||
@@ -472,16 +471,23 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let shown = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
|
||||
// A real reparent moves the subtree's effective scale and anchors — a
|
||||
// layout-gate input, and the scale is a measure-key input the subtree's
|
||||
// FontStrings cannot name one by one (human-rate; the sweep walks once).
|
||||
model.touch_layout();
|
||||
// A real reparent moves the subtree's effective scale — a layout-gate input,
|
||||
// and a measure-key input the subtree's FontStrings cannot name one by one.
|
||||
//
|
||||
// **The layout half NAMES its nodes** (decision 2314). This was the conservative
|
||||
// touch, on the reading that a reparent is human-rate; a pooled widget's recycle
|
||||
// is a reparent, and `WOW_LAYOUT_DERIVE_TRACE` put every sampled give-up in a
|
||||
// quest-accept spike on this one line — 58 whole-graph derivations in one frame,
|
||||
// 128 ms of a 161 ms hitch. `touch_layout_reparent`'s doc has why the subtree is
|
||||
// the whole of what moved. The measure sweep keeps the wide form: a measure key
|
||||
// is not a layout input and its sweep is a different cost.
|
||||
model.touch_layout_reparent(h);
|
||||
model.touch_measure_all();
|
||||
shown
|
||||
};
|
||||
event::fire_visibility_changes(lua, shown);
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
// Strata / level / scale / alpha
|
||||
m.set(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::region_map::{set_shared, Side};
|
||||
use crate::script::{Model, SCREEN};
|
||||
use crate::widget::FrameHandle;
|
||||
|
||||
@@ -28,49 +29,52 @@ fn frame_ladder_context(lua: &Lua, h: FrameHandle) -> (String, String) {
|
||||
/// Populate `m`'s layout (anchor/size) methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// Layout: SetPoint / ClearAllPoints / SetWidth / SetHeight / GetWidth / GetHeight
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"SetPoint",
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| set_point(lua, &this, &rest))?,
|
||||
|lua, (this, rest): (Table, MultiValue)| set_point(lua, &this, &rest),
|
||||
)?;
|
||||
m.set(
|
||||
"ClearAllPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
// Every layout setter here follows one law: mutate ONLY on an actual value change,
|
||||
// and report the change to the tier-1 epoch (`touch_layout`). The compare is what
|
||||
// keeps an idempotent per-frame caller (the classic OnUpdate re-SetPoint idiom) from
|
||||
// pinning the gate open — the same absorption the fingerprint gives, paid once at
|
||||
// the write instead of per-frame over the whole model.
|
||||
//
|
||||
// **And it NAMES its node** (decision 2114, completing 1625's migration). Dropping
|
||||
// every anchor is a retarget whose NEW target list is empty, and both lists are right
|
||||
// here — so the cached graph's edges get unlinked instead of thrown away. Left on the
|
||||
// conservative touch, this was the one recurring `[layout-derive]` site in a live
|
||||
// hover sweep: `Bagnon_AnchorTooltip` opens with `GameTooltip:ClearAllPoints()` and
|
||||
// then asks `frame:GetLeft()`, so the whole graph was re-derived INSIDE the handler,
|
||||
// once per hovered item, and billed to `[ui-handlers]`' `OnEnter`.
|
||||
let old: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
|
||||
Some(input) if !input.anchors.is_empty() => {
|
||||
let old = input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors.clear();
|
||||
Some(old)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(old) = old {
|
||||
model.touch_layout_retarget_frame(h, &old, &[]);
|
||||
set_shared(lua, m, Side::Frame, "ClearAllPoints", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
// Every layout setter here follows one law: mutate ONLY on an actual value change,
|
||||
// and report the change to the tier-1 epoch (`touch_layout`). The compare is what
|
||||
// keeps an idempotent per-frame caller (the classic OnUpdate re-SetPoint idiom) from
|
||||
// pinning the gate open — the same absorption the fingerprint gives, paid once at
|
||||
// the write instead of per-frame over the whole model.
|
||||
//
|
||||
// **And it NAMES its node** (decision 2114, completing 1625's migration). Dropping
|
||||
// every anchor is a retarget whose NEW target list is empty, and both lists are right
|
||||
// here — so the cached graph's edges get unlinked instead of thrown away. Left on the
|
||||
// conservative touch, this was the one recurring `[layout-derive]` site in a live
|
||||
// hover sweep: `Bagnon_AnchorTooltip` opens with `GameTooltip:ClearAllPoints()` and
|
||||
// then asks `frame:GetLeft()`, so the whole graph was re-derived INSIDE the handler,
|
||||
// once per hovered item, and billed to `[ui-handlers]`' `OnEnter`.
|
||||
let old: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
|
||||
Some(input) if !input.anchors.is_empty() => {
|
||||
let old = input.anchors.iter().map(|a| a.relative_to).collect();
|
||||
input.anchors.clear();
|
||||
Some(old)
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
_ => None,
|
||||
};
|
||||
if let Some(old) = old {
|
||||
model.touch_layout_retarget_frame(h, &old, &[]);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// GetPoint([n]) → point, relativeTo, relativePoint, xOfs, yOfs — the n-th (1-based, default
|
||||
// first) anchor. relativeTo is nil when the target is the screen root (the client returns
|
||||
// UIParent there; ours is the distinct `script::SCREEN` sentinel, which has no wrapper of its
|
||||
// own — the arena's `UIParent` frame is a different handle — stated, a consensus-list call).
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"GetPoint",
|
||||
lua.create_function(|lua, (this, n): (Table, Option<i64>)| {
|
||||
|lua, (this, n): (Table, Option<i64>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let anchor = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -96,28 +100,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
Value::Number(f64::from(a.x_off)),
|
||||
Value::Number(f64::from(a.y_off)),
|
||||
))
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
// GetNumPoints() → how many anchors this frame carries. On the Region map (`0x87c9b8`), so
|
||||
// every widget answers it — the region twin shipped first and noted this side was missing;
|
||||
// collapsing the map to one implementation each (decision 1501) is what made the gap fatal
|
||||
// rather than merely absent, and this is the arm it was missing.
|
||||
m.set(
|
||||
"GetNumPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.layout_inputs
|
||||
.get(&h)
|
||||
.map_or(0, |i| i.anchors.len() as i64))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetNumPoints", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.layout_inputs
|
||||
.get(&h)
|
||||
.map_or(0, |i| i.anchors.len() as i64))
|
||||
})?;
|
||||
// SetAllPoints([relativeTo]) — pin TOPLEFT+BOTTOMRIGHT to the target (default: the parent),
|
||||
// the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path).
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"SetAllPoints",
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
|lua, (this, rest): (Table, MultiValue)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// `who`/`$parent` first, then the `_G` read, then the guard — see `set_point` and
|
||||
// `object::NamedTarget`.
|
||||
@@ -145,11 +149,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"SetWidth",
|
||||
lua.create_function(|lua, (this, w): (Table, f32)| {
|
||||
|lua, (this, w): (Table, f32)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
@@ -161,11 +168,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
model.note_authored_size(h);
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Frame,
|
||||
"SetHeight",
|
||||
lua.create_function(|lua, (this, ht): (Table, f32)| {
|
||||
|lua, (this, ht): (Table, f32)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
@@ -176,51 +186,42 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
model.note_authored_size(h);
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
// **No `SetSize`.** It is an Era geometry verb, in neither the Frame nor the Region method
|
||||
// table of 1.12 — and neither the stock chain nor either addon corpus writes it (decision
|
||||
// 2142's census). The two setters above are the era's whole size surface.
|
||||
m.set(
|
||||
"GetWidth",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(size_read(&model, h, true))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetHeight",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(size_read(&model, h, false))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetWidth", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(size_read(&model, h, true))
|
||||
})?;
|
||||
set_shared(lua, m, Side::Frame, "GetHeight", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(size_read(&model, h, false))
|
||||
})?;
|
||||
|
||||
// GetCenter() → centerX, centerY — the resolved rect's center in LOCAL units (y-up; screen ÷
|
||||
// the frame's effective scale — the client's convention: coordinate getters report the frame's
|
||||
// own scaled space, and callers divide GetCursorPosition (screen px) by GetEffectiveScale to
|
||||
// meet them there; the ref world map's hover math does exactly that). nil pair before the
|
||||
// first resolve, like the edge readers.
|
||||
m.set(
|
||||
"GetCenter",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let inv = 1.0 / eff_scale(&model, h);
|
||||
Ok(match model.resolved.get(&h) {
|
||||
Some(r) => (
|
||||
Value::Number(f64::from((r.left + r.right) * 0.5 * inv)),
|
||||
Value::Number(f64::from((r.bottom + r.top) * 0.5 * inv)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, "GetCenter", |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let inv = 1.0 / eff_scale(&model, h);
|
||||
Ok(match model.resolved.get(&h) {
|
||||
Some(r) => (
|
||||
Value::Number(f64::from((r.left + r.right) * 0.5 * inv)),
|
||||
Value::Number(f64::from((r.bottom + r.top) * 0.5 * inv)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?;
|
||||
|
||||
// GetEffectiveScale() — the frame's real effective scale (parentScale · ownScale, the arena's
|
||||
// propagated product). The ROOT factor is 1, and that is not because benilla lacks a `uiScale`
|
||||
@@ -250,23 +251,20 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
("GetTop", 2u8),
|
||||
("GetBottom", 3u8),
|
||||
] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let inv = 1.0 / eff_scale(&model, h);
|
||||
Ok(model.resolved.get(&h).map(|r| {
|
||||
inv * match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Frame, name, move |lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
settle(lua);
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let inv = 1.0 / eff_scale(&model, h);
|
||||
Ok(model.resolved.get(&h).map(|r| {
|
||||
inv * match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}
|
||||
}))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED};
|
||||
use super::object::{
|
||||
anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget,
|
||||
};
|
||||
use super::region_map::{set_shared, Side};
|
||||
use super::{
|
||||
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
|
||||
REG_TEXTURE_META, REG_TEXTURE_METHODS, REG_TITLE_META, REG_TITLE_METHODS, REG_WRAPPERS, SCREEN,
|
||||
@@ -211,12 +212,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// **`__index` is the method TABLE, not a dispatcher function** (decision 2310) — here and at
|
||||
// the three leaf metatables below. A Rust `__index` makes every `t.SetTexture` a Lua→Rust→Lua
|
||||
// round trip plus a named-registry string lookup; measured at ~195 ns against ~9 ns for the
|
||||
// table form, paid by every widget method access in the client. Nothing mutates these tables
|
||||
// after install (`super::region_map::install` writes into them in place, and runs inside
|
||||
// `super::object::install`), so holding the table itself cannot go stale.
|
||||
let region_meta = lua.create_table()?;
|
||||
let region_index = lua.create_function(|lua, (_this, key): (Table, Value)| {
|
||||
let methods: Table = lua.named_registry_value(REG_REGION_METHODS)?;
|
||||
methods.get::<Value>(key)
|
||||
})?;
|
||||
region_meta.set("__index", region_index)?;
|
||||
region_meta.set(
|
||||
"__index",
|
||||
lua.named_registry_value::<Table>(REG_REGION_METHODS)?,
|
||||
)?;
|
||||
lua.set_named_registry_value(REG_REGION_META, region_meta)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -236,17 +242,14 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
//
|
||||
// Resolution is [`region_name_of`], shared with `IsObjectType`'s `Usage:` text so the two can
|
||||
// never disagree about what this region is called.
|
||||
m.set(
|
||||
"GetName",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let id = decode_id(&this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match region_name_of(&model, id) {
|
||||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, &m, Side::Region, "GetName", |lua, this: Table| {
|
||||
let id = decode_id(&this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match region_name_of(&model, id) {
|
||||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?;
|
||||
|
||||
// ── GetObjectType / IsObjectType: the last two of the Region map (1244 §4 closed) ───────────
|
||||
//
|
||||
@@ -258,13 +261,16 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
// `GetObjectType` is a per-class `.data` `const char*` read through `vtable[+0x1c]` — Texture
|
||||
// `0x773480` → `"Texture"`, FontString `0x7735d0` → `"FontString"` — pushed with
|
||||
// `lua_pushstring`, exactly one value, extra arguments ignored with no arity check.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
&m,
|
||||
Side::Region,
|
||||
"GetObjectType",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(region_type_name(&model, rh))
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// `IsObjectType(name)` — binding `0x7a1290`. Four traps, all verified, all here:
|
||||
@@ -288,9 +294,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
// in place, so `tex:IsObjectType(5)` compares against `"5"` and quietly answers nil — we
|
||||
// format it and compare for real rather than short-circuiting, though no type name is
|
||||
// numeric so the answer is nil either way.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
&m,
|
||||
Side::Region,
|
||||
"IsObjectType",
|
||||
lua.create_function(|lua, (this, want): (Table, Value)| {
|
||||
|lua, (this, want): (Table, Value)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let want = match &want {
|
||||
@@ -308,7 +317,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
let leaf = region_type_name(&model, rh);
|
||||
let hit = want.eq_ignore_ascii_case(leaf) || want.eq_ignore_ascii_case("Region");
|
||||
Ok(if hit { Value::Number(1.0) } else { Value::Nil })
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// SetParent(frame) — **a Texture/FontString really does have this**, and we were the ones
|
||||
@@ -335,9 +344,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
//
|
||||
// The mechanism half — full re-link, layer and sub-level preserved, `nil` = orphaned but not
|
||||
// destroyed — is [`crate::widget::WidgetArena::set_region_owner`]'s doc.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
&m,
|
||||
Side::Region,
|
||||
"SetParent",
|
||||
lua.create_function(|lua, args: mlua::MultiValue| {
|
||||
|lua, args: mlua::MultiValue| {
|
||||
let mut it = args.into_iter();
|
||||
let Some(Value::Table(this)) = it.next() else {
|
||||
return Err(mlua::Error::runtime("SetParent: expected a region"));
|
||||
@@ -393,7 +405,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
|
||||
@@ -515,20 +527,12 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
(REG_FONTSTRING_META, REG_FONTSTRING_METHODS),
|
||||
] {
|
||||
let meta = lua.create_table()?;
|
||||
let index = lua.create_function(move |lua, (_this, key): (Table, Value)| {
|
||||
let methods: Table = lua.named_registry_value(methods_key)?;
|
||||
methods.get::<Value>(key)
|
||||
})?;
|
||||
meta.set("__index", index)?;
|
||||
meta.set("__index", lua.named_registry_value::<Table>(methods_key)?)?;
|
||||
lua.set_named_registry_value(meta_key, meta)?;
|
||||
}
|
||||
|
||||
let title_meta = lua.create_table()?;
|
||||
let title_index = lua.create_function(|lua, (_this, key): (Table, Value)| {
|
||||
let methods: Table = lua.named_registry_value(REG_TITLE_METHODS)?;
|
||||
methods.get::<Value>(key)
|
||||
})?;
|
||||
title_meta.set("__index", title_index)?;
|
||||
title_meta.set("__index", title.clone())?;
|
||||
lua.set_named_registry_value(REG_TITLE_METHODS, title)?;
|
||||
lua.set_named_registry_value(REG_TITLE_META, title_meta)?;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use mlua::{Lua, MultiValue, Table, Value};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::object::anchor_args::{parse_set_all_points, resolve_rel_target};
|
||||
use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name};
|
||||
use crate::script::region_map::{set_shared, Side};
|
||||
use crate::script::{Model, SCREEN};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
@@ -18,9 +19,12 @@ use super::{
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// Region explicit size — fills the axes the region's anchors don't pin (unread under an
|
||||
// implicit SetAllPoints's two corners; decision 1310).
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"SetWidth",
|
||||
lua.create_function(|lua, (this, w): (Table, f32)| {
|
||||
|lua, (this, w): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
@@ -35,12 +39,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
model.touch_measure(rh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"SetHeight",
|
||||
lua.create_function(|lua, (this, h): (Table, f32)| {
|
||||
|lua, (this, h): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
@@ -55,21 +62,19 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
model.touch_measure(rh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// **No `SetSize`** — the frame twin's note in `object/layout_methods.rs` applies here
|
||||
// unchanged: an Era verb 1.12's Region map does not carry (decision 2142).
|
||||
|
||||
m.set(
|
||||
"GetWidth",
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, "GetWidth", |lua, this: Table| {
|
||||
Ok(measured_wh(lua, &this)?.0)
|
||||
})?;
|
||||
|
||||
m.set(
|
||||
"GetHeight",
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, "GetHeight", |lua, this: Table| {
|
||||
Ok(measured_wh(lua, &this)?.1)
|
||||
})?;
|
||||
|
||||
// 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` —
|
||||
@@ -84,38 +89,39 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
("GetTop", 2u8),
|
||||
("GetBottom", 3u8),
|
||||
] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |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(model.region_resolved.get(&rh).map(|r| {
|
||||
inv * match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, name, move |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(model.region_resolved.get(&rh).map(|r| {
|
||||
inv * match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}
|
||||
}))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions
|
||||
// ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo`
|
||||
// defaults to the **owner frame**; a named one may be a frame or a sibling region (the real
|
||||
// XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`).
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"SetPoint",
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
region_set_point(lua, &this, &rest)
|
||||
})?,
|
||||
|lua, (this, rest): (Table, MultiValue)| region_set_point(lua, &this, &rest),
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"ClearAllPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
@@ -129,12 +135,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
model.touch_layout_retarget_region(rh, &old, &[]);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"SetAllPoints",
|
||||
lua.create_function(|lua, (this, rest): (Table, MultiValue)| {
|
||||
|lua, (this, rest): (Table, MultiValue)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
// `who`/`$parent` first, then the `_G` read, then the guard — `region_ladder_context`.
|
||||
let (who, base) = region_ladder_context(lua, rh);
|
||||
@@ -160,7 +169,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
|
||||
// ── The rest of the Region map `0xcf54b4` (wow-re `font-object-lua-surface.md`) ──────────────
|
||||
@@ -174,17 +183,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
|
||||
// GetParent() → the OWNER frame's wrapper. A region always has one (`region_owner_id` falls
|
||||
// back to the owner for every unresolved case), so unlike the frame twin this never answers nil.
|
||||
m.set(
|
||||
"GetParent",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let owner = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
region_owner_id(&mut model, rh)
|
||||
};
|
||||
frame_wrapper(lua, owner)
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, "GetParent", |lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let owner = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
region_owner_id(&mut model, rh)
|
||||
};
|
||||
frame_wrapper(lua, owner)
|
||||
})?;
|
||||
|
||||
// GetCenter() → the resolved rect's midpoint, or a nil PAIR before the first resolve — the same
|
||||
// contract, and the same source, as the GetLeft/GetRight/GetTop/GetBottom readers above.
|
||||
@@ -193,35 +199,29 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// readers report raw resolved units; scaling only the centre would make `GetCenter()` disagree
|
||||
// with `(GetLeft() + GetRight()) / 2` on any scaled subtree — a contradiction inside one method
|
||||
// table is worse than a missing division, and regions have no scale of their own to divide by.
|
||||
m.set(
|
||||
"GetCenter",
|
||||
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(inv * (r.left + r.right) * 0.5)),
|
||||
Value::Number(f64::from(inv * (r.bottom + r.top) * 0.5)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, "GetCenter", |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(inv * (r.left + r.right) * 0.5)),
|
||||
Value::Number(f64::from(inv * (r.bottom + r.top) * 0.5)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?;
|
||||
|
||||
// GetNumPoints() → how many anchors this region carries. Absent on our FRAMES too, which is the
|
||||
// same drift one table up; this side is what the corpus named.
|
||||
m.set(
|
||||
"GetNumPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.map_or(0, |d| d.anchors.len() as i64))
|
||||
})?,
|
||||
)?;
|
||||
set_shared(lua, m, Side::Region, "GetNumPoints", |lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.map_or(0, |d| d.anchors.len() as i64))
|
||||
})?;
|
||||
|
||||
// GetPoint([n]) → point, relativeTo, relativePoint, xOfs, yOfs — the n-th (1-based, default
|
||||
// first) anchor, mirroring the frame twin including its out-of-range answer (five nils).
|
||||
@@ -231,9 +231,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// (`region.rs`'s `resolve_target`: frames first, then the region-name registry). So the id is
|
||||
// matched against both tables and answered with the matching wrapper kind; handing back a frame
|
||||
// wrapper for a region id would be a working-looking handle onto the wrong object.
|
||||
m.set(
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
Side::Region,
|
||||
"GetPoint",
|
||||
lua.create_function(|lua, (this, n): (Table, Option<i64>)| {
|
||||
|lua, (this, n): (Table, Option<i64>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let anchor = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
@@ -267,7 +270,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
Value::Number(f64::from(a.x_off)),
|
||||
Value::Number(f64::from(a.y_off)),
|
||||
))
|
||||
})?,
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@
|
||||
//! opposite reason — in neither table because 1.12 has no such verb at all; decision 2142 removed
|
||||
//! it rather than filing it.) The map is the unit.
|
||||
|
||||
use mlua::{Function, Lua, MultiValue, Table, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use mlua::{FromLuaMulti, IntoLuaMulti, Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::object::decode_id;
|
||||
use super::{
|
||||
@@ -67,7 +70,8 @@ use super::{
|
||||
|
||||
/// Which side of the object model a wrapper's `T[0]` id names. Ids come from one counter
|
||||
/// ([`Model::next_id`]), so a region id can never be mistaken for a frame's.
|
||||
enum Side {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub(super) enum Side {
|
||||
Frame,
|
||||
Region,
|
||||
}
|
||||
@@ -86,6 +90,70 @@ fn side_of(lua: &Lua, this: &Table) -> mlua::Result<Option<Side>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// One side's implementation of one of the 19, type-erased to the variadic ABI.
|
||||
///
|
||||
/// **The erasure is the point** (decision 2310). The dispatcher used to hold each side as an
|
||||
/// `mlua::Function` and reach it with `Function::call`, which re-enters Lua: every argument and
|
||||
/// every return value round-trips through mlua's ref thread twice, and the whole call is wrapped
|
||||
/// in a `lua_pcall`. Measured in a release build, a `MultiValue` relay costs **~420 ns** against
|
||||
/// ~40 ns for a scalar one, and a bridged `GetWidth()` measured **~750 ns against ~99 ns** for an
|
||||
/// unbridged frame method — a 7x tax on `SetPoint`, `GetWidth`, `ClearAllPoints`, `GetParent`, the
|
||||
/// verbs every UI calls most. An `Rc<dyn Fn>` is one ordinary Rust call.
|
||||
///
|
||||
/// The ABI is exactly what `Lua::create_function` builds internally for a typed body — see
|
||||
/// [`shared`], which is the only thing that makes one — so nothing about argument conversion,
|
||||
/// arity or coercion changes by going through it.
|
||||
pub(super) type Arm = Rc<dyn Fn(&Lua, MultiValue) -> mlua::Result<MultiValue>>;
|
||||
|
||||
/// The arms the two method-table installers register, collected for [`install`].
|
||||
///
|
||||
/// It lives in the VM's `app_data` rather than being threaded through four `install` signatures:
|
||||
/// the installers are deep (`object::install` → `layout_methods::install`, `region::install` →
|
||||
/// `region::layout::install`) and the collection is install-time scaffolding, removed by
|
||||
/// [`install`] the moment it has been consumed.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Arms(HashMap<(Side, &'static str), Arm>);
|
||||
|
||||
/// Register one of the [`REGION_MAP_METHODS`] into its side's method table **and** record its arm.
|
||||
///
|
||||
/// Every one of the 38 (19 names x 2 sides) goes through here instead of
|
||||
/// `m.set(name, lua.create_function(body)?)?`, and [`install`] fails loudly if one did not — so a
|
||||
/// name added to the map without both arms cannot reach an addon as a half-registered method.
|
||||
/// The table entry it writes is transient: [`install`] replaces all 19 in every table with the
|
||||
/// shared dispatcher. It exists so the pre-bridge tables are still complete for anything that
|
||||
/// reads them in between (the leaf tables are *copied* out of the region table, mid-install).
|
||||
pub(super) fn set_shared<A, R, F>(
|
||||
lua: &Lua,
|
||||
m: &Table,
|
||||
side: Side,
|
||||
name: &'static str,
|
||||
f: F,
|
||||
) -> mlua::Result<()>
|
||||
where
|
||||
A: FromLuaMulti + 'static,
|
||||
R: IntoLuaMulti + 'static,
|
||||
F: Fn(&Lua, A) -> mlua::Result<R> + 'static,
|
||||
{
|
||||
let arm: Arm =
|
||||
Rc::new(move |lua, args| f(lua, A::from_lua_multi(args, lua)?)?.into_lua_multi(lua));
|
||||
let entry = arm.clone();
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, args: MultiValue| entry(lua, args))?,
|
||||
)?;
|
||||
lua.app_data_mut::<Arms>()
|
||||
.expect("Region-map arms — installed by `super::object::install`")
|
||||
.0
|
||||
.insert((side, name), arm);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the arm collection. Called once, at the top of [`super::object::install`], before either
|
||||
/// method table is built; [`install`] closes it.
|
||||
pub(super) fn open_arms(lua: &Lua) {
|
||||
lua.set_app_data(Arms::default());
|
||||
}
|
||||
|
||||
/// Replace each of the 19 Region-map entries in every table the chain reaches with one shared
|
||||
/// function that dispatches on the receiver.
|
||||
///
|
||||
@@ -103,31 +171,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
.map(|k| lua.named_registry_value::<Table>(k))
|
||||
.collect::<mlua::Result<_>>()?;
|
||||
|
||||
let arms = lua
|
||||
.remove_app_data::<Arms>()
|
||||
.expect("Region-map arms — opened by `open_arms`");
|
||||
|
||||
for name in REGION_MAP_METHODS {
|
||||
// Both arms must already exist. A missing one is not a thing to paper over with a
|
||||
// one-sided shared function: the reference gives every widget all 19, so a gap on either
|
||||
// side is a hole in the surface and says so here rather than at some addon's call site.
|
||||
// (`GetNumPoints` was exactly that hole on the frame side until this module went in.)
|
||||
let on_frame: Function = frame.get(name).map_err(|_| {
|
||||
mlua::Error::runtime(format!("Region map: the FRAME table has no {name}"))
|
||||
})?;
|
||||
let on_region: Function = region.get(name).map_err(|_| {
|
||||
mlua::Error::runtime(format!("Region map: the REGION table has no {name}"))
|
||||
})?;
|
||||
// Both arms must exist. A missing one is not a thing to paper over with a one-sided shared
|
||||
// function: the reference gives every widget all 19, so a gap on either side is a hole in
|
||||
// the surface and says so here rather than at some addon's call site. (`GetNumPoints` was
|
||||
// exactly that hole on the frame side until this module went in.)
|
||||
let arm = |side: Side, which: &str| -> mlua::Result<Arm> {
|
||||
arms.0.get(&(side, name)).cloned().ok_or_else(|| {
|
||||
mlua::Error::runtime(format!(
|
||||
"Region map: the {which} side never registered {name} through `set_shared`"
|
||||
))
|
||||
})
|
||||
};
|
||||
let on_frame = arm(Side::Frame, "FRAME")?;
|
||||
let on_region = arm(Side::Region, "REGION")?;
|
||||
let shared = lua.create_function(move |lua, args: MultiValue| {
|
||||
// The receiver is argument 1 on every one of the 19 — a method call always passes it,
|
||||
// and a pulled-off `Api._Height(x)` call passes it as the only argument.
|
||||
let Some(Value::Table(this)) = args.iter().next().cloned() else {
|
||||
return Err(mlua::Error::runtime(
|
||||
"expected a frame or region as the first argument",
|
||||
));
|
||||
// and a pulled-off `Api._Height(x)` call passes it as the only argument. Borrowed
|
||||
// rather than cloned: an mlua `Value::Table` clone registers a second reference in the
|
||||
// ref thread, and this is the entry point for the verbs a map-note redraw calls
|
||||
// thousands of times.
|
||||
let side = {
|
||||
let Some(Value::Table(this)) = args.iter().next() else {
|
||||
return Err(mlua::Error::runtime(
|
||||
"expected a frame or region as the first argument",
|
||||
));
|
||||
};
|
||||
side_of(lua, this)?
|
||||
};
|
||||
match side_of(lua, &this)? {
|
||||
Some(Side::Frame) => on_frame.call::<MultiValue>(args),
|
||||
Some(Side::Region) => on_region.call::<MultiValue>(args),
|
||||
match side {
|
||||
// One ordinary Rust call, not a re-entry into Lua — see [`Arm`].
|
||||
Some(Side::Frame) => on_frame(lua, args),
|
||||
Some(Side::Region) => on_region(lua, args),
|
||||
// A wrapper whose widget is gone. Deliberately ONE message for both sides — at
|
||||
// this point the receiver's kind is exactly what could not be established, and
|
||||
// each side's old wording claimed it.
|
||||
// each side's own wording claimed it.
|
||||
None => Err(mlua::Error::runtime("stale or invalid widget handle")),
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! **What one widget method lookup costs** — the bench behind the `__index` table (decision 2310).
|
||||
//!
|
||||
//! Every `frame:SetPoint(...)` in FrameXML and in every addon begins with a table index on the
|
||||
//! wrapper, which misses and falls to the metatable's `__index`. That path is on the hottest edge
|
||||
//! this engine has: Questie redraws its map notes with ~19 widget calls per cluster over hundreds
|
||||
//! of clusters, and a live sample of a quest-accept spike put **20 % of the whole UI tick** inside
|
||||
//! the metamethod dispatch alone — before any method body ran.
|
||||
//!
|
||||
//! The two rows below are the measurement that claim rests on, and the regression guard for it:
|
||||
//!
|
||||
//! - `miss` — `local m = f.NoSuchMethod`, the duck-type probe addons use (`if f.SetValue then`).
|
||||
//! It is the WORST case: a dispatcher walks every registry in the kind chain and then the shared
|
||||
//! table before answering nil.
|
||||
//! - `hit` — `local m = f.GetWidth`, the lookup every real call pays.
|
||||
//!
|
||||
//! Run: `cargo test --release -p benilla-ui --lib -- --ignored --nocapture dispatch_bench`.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// Lookups per timed loop — big enough that the loop's own overhead is a rounding error at the
|
||||
/// nanosecond scale these rows live at.
|
||||
const N: usize = 400_000;
|
||||
|
||||
/// Timed repeats per row; the reported number is the fastest (see [`dispatch_bench`]'s `floor`).
|
||||
const ROUNDS: usize = 5;
|
||||
|
||||
fn ns_per(t: Instant, n: usize) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e9 / n as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "bench — run explicitly with --release --nocapture"]
|
||||
fn dispatch_bench() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
F = CreateFrame("Frame")
|
||||
B = CreateFrame("Button")
|
||||
T = F:CreateTexture()
|
||||
S = F:CreateFontString()
|
||||
"#,
|
||||
)
|
||||
.expect("fixtures");
|
||||
|
||||
// A plain Lua table with a table `__index` — the floor this path could reach, and what the
|
||||
// reference client's flat `{name, lua_CFunction}` probe costs in C.
|
||||
// **The MINIMUM of [`ROUNDS`] runs, never the mean.** This machine builds while it measures —
|
||||
// a neighbouring worktree's `cargo` saturates ten cores — and a mean of a contended loop reads
|
||||
// whatever else was running. The minimum is the closest thing to the uncontended cost that a
|
||||
// shared machine can report, and it is what makes two rows of one run comparable at all.
|
||||
let floor = |what: &str, chunk: &str| {
|
||||
let mut best = f64::INFINITY;
|
||||
for _ in 0..ROUNDS {
|
||||
let t = Instant::now();
|
||||
s.run(chunk).expect("bench chunk");
|
||||
best = best.min(ns_per(t, N));
|
||||
}
|
||||
println!("[dispatch] {what:>26} {best:7.1} ns");
|
||||
};
|
||||
|
||||
floor(
|
||||
"baseline (table meta)",
|
||||
&format!(
|
||||
"local base = {{ GetWidth = function() end }}
|
||||
local o = setmetatable({{}}, {{ __index = base }})
|
||||
for _ = 1, {N} do local m = o.GetWidth end"
|
||||
),
|
||||
);
|
||||
floor(
|
||||
"Frame hit",
|
||||
&format!("for _ = 1, {N} do local m = F.GetWidth end"),
|
||||
);
|
||||
floor(
|
||||
"Frame miss",
|
||||
&format!("for _ = 1, {N} do local m = F.NoSuchMethod end"),
|
||||
);
|
||||
floor(
|
||||
"Button hit",
|
||||
&format!("for _ = 1, {N} do local m = B.GetWidth end"),
|
||||
);
|
||||
floor(
|
||||
"Button own hit",
|
||||
&format!("for _ = 1, {N} do local m = B.SetText end"),
|
||||
);
|
||||
floor(
|
||||
"Button miss",
|
||||
&format!("for _ = 1, {N} do local m = B.NoSuchMethod end"),
|
||||
);
|
||||
floor(
|
||||
"Texture hit",
|
||||
&format!("for _ = 1, {N} do local m = T.GetWidth end"),
|
||||
);
|
||||
floor(
|
||||
"Texture miss",
|
||||
&format!("for _ = 1, {N} do local m = T.NoSuchMethod end"),
|
||||
);
|
||||
floor(
|
||||
"FontString hit",
|
||||
&format!("for _ = 1, {N} do local m = S.GetWidth end"),
|
||||
);
|
||||
|
||||
// The whole call, not just the lookup — what an addon's `f:GetWidth()` actually costs, split
|
||||
// so the two taxes are separable: `GetFrameLevel` is a frame-only binding and measures one
|
||||
// Rust hop, while `GetWidth` is one of [`crate::script::region_map`]'s 19 shared names and
|
||||
// pays a SECOND hop — the bridge re-enters Lua to call the per-side arm.
|
||||
floor(
|
||||
"GetFrameLevel() call",
|
||||
&format!("for _ = 1, {N} do local l = F:GetFrameLevel() end"),
|
||||
);
|
||||
floor(
|
||||
"GetWidth() call (bridged)",
|
||||
&format!("for _ = 1, {N} do local w = F:GetWidth() end"),
|
||||
);
|
||||
floor(
|
||||
"SetPoint() call (bridged)",
|
||||
&format!("for _ = 1, {N} do F:SetPoint(\"CENTER\", 0, 0) end"),
|
||||
);
|
||||
|
||||
// **The settle tax.** Every geometry GETTER (`GetWidth`, `GetHeight`, `GetLeft`, `GetPoint`,
|
||||
// `GetCenter`) calls `layout_methods::settle`, i.e. a whole `resolve_layout`. The tier-1 epoch
|
||||
// gate makes that free while nothing has moved — which is what the `GetWidth() call` row above
|
||||
// measures — but ANY layout write in between reopens it, so the read-after-write idiom pays a
|
||||
// resolve per read. That idiom is not exotic: Astrolabe repositions a minimap icon with
|
||||
// `GetWidth` → `ClearAllPoints` → `SetPoint`, per icon, and Questie places dozens.
|
||||
//
|
||||
// Here the model holds four frames, so a resolve is nearly free and the gap is the mechanism
|
||||
// alone. In a live client the roster is thousands of frames and ten thousand anchored regions
|
||||
// — the same gap, three orders of magnitude wider.
|
||||
floor(
|
||||
"write only",
|
||||
&format!("for i = 1, {N} do F:SetPoint(\"CENTER\", i, 0) end"),
|
||||
);
|
||||
floor(
|
||||
"write + read (settles)",
|
||||
&format!("for i = 1, {N} do F:SetPoint(\"CENTER\", i, 0) local w = F:GetWidth() end"),
|
||||
);
|
||||
floor(
|
||||
"write + read x3 (settles)",
|
||||
&format!(
|
||||
"for i = 1, {N} do F:SetPoint(\"CENTER\", i, 0) local w = F:GetWidth() local h = F:GetHeight() local l = F:GetLeft() end"
|
||||
),
|
||||
);
|
||||
|
||||
// **The control that says WHERE the bridged calls' extra microsecond goes.** `__direct` is one
|
||||
// mlua closure; `__wrapped` is one mlua closure that calls another through `Function::call` —
|
||||
// the exact shape `region_map`'s shared arm has. The gap between these two rows is what a
|
||||
// re-entrant Lua call costs, and it is the number that decides whether those 19 names are worth
|
||||
// restructuring into a single Rust body with an internal branch (the reference's own shape: one
|
||||
// C function doing a virtual dispatch on the receiver).
|
||||
{
|
||||
let lua = s.lua();
|
||||
let direct = lua
|
||||
.create_function(|_, n: i64| Ok(n + 1))
|
||||
.expect("direct fn");
|
||||
lua.globals().set("__direct", direct.clone()).expect("set");
|
||||
let wrapped = lua
|
||||
.create_function(move |_, n: i64| direct.call::<i64>(n))
|
||||
.expect("wrapped fn");
|
||||
lua.globals().set("__wrapped", wrapped).expect("set");
|
||||
|
||||
// The same pair again in the SHAPE the bridge actually has: a variadic `MultiValue` whose
|
||||
// first value is the receiver table. `Table` and `MultiValue` are the two conversions the
|
||||
// scalar controls above leave out, and between them they are most of the gap.
|
||||
let mv_direct = lua
|
||||
.create_function(|_, args: mlua::MultiValue| Ok(args.len() as i64))
|
||||
.expect("mv direct");
|
||||
lua.globals()
|
||||
.set("__mv_direct", mv_direct.clone())
|
||||
.expect("set");
|
||||
let mv_relay = lua
|
||||
.create_function(move |_, args: mlua::MultiValue| {
|
||||
mv_direct.call::<mlua::MultiValue>(args)
|
||||
})
|
||||
.expect("mv relay");
|
||||
lua.globals().set("__mv_relay", mv_relay).expect("set");
|
||||
}
|
||||
floor(
|
||||
"ctrl: 1 mlua hop",
|
||||
&format!("for _ = 1, {N} do local v = __direct(1) end"),
|
||||
);
|
||||
floor(
|
||||
"ctrl: mlua hop + relay",
|
||||
&format!("for _ = 1, {N} do local v = __wrapped(1) end"),
|
||||
);
|
||||
floor(
|
||||
"ctrl: variadic hop",
|
||||
&format!("for _ = 1, {N} do local v = __mv_direct(F, 1, 2) end"),
|
||||
);
|
||||
floor(
|
||||
"ctrl: variadic hop + relay",
|
||||
&format!("for _ = 1, {N} do local v = __mv_relay(F, 1, 2) end"),
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod channel;
|
||||
mod common;
|
||||
mod cooldown;
|
||||
mod create_frame_template;
|
||||
mod dispatch_bench;
|
||||
mod end_to_end;
|
||||
mod events;
|
||||
mod font_object;
|
||||
|
||||
@@ -203,3 +203,64 @@ fn the_binding_raises_on_cycle_bad_name_and_absent_argument() {
|
||||
"0x76ab20 skips everything — the level is not re-derived"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A reparent under a scale change still moves the child's resolved rect** — the falsifier for
|
||||
/// decision 2314's claim that `SetParent` is a value-only write the ledger can NAME.
|
||||
///
|
||||
/// The gate's two tiers police each other under `WOW_LAYOUT_VERIFY`, which is forced on for this
|
||||
/// crate's tests: an incremental pass that names its nodes is re-run from scratch and the rects
|
||||
/// must be identical. So this test does not have to assert the epoch bookkeeping — it only has to
|
||||
/// make the reparent *observable in geometry*, which is what the conservative touch used to buy
|
||||
/// wholesale. A child anchored inside a parent inherits `parentScale · ownScale` as its
|
||||
/// `LayoutInput.scale`; move it to a parent with a different scale and both its offsets and its
|
||||
/// span change.
|
||||
#[test]
|
||||
fn a_reparent_under_a_scale_change_moves_the_childs_rect() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
one = CreateFrame("Frame", "ScaleOne")
|
||||
one:SetPoint("BOTTOMLEFT", 0, 0); one:SetWidth(800); one:SetHeight(600)
|
||||
two = CreateFrame("Frame", "ScaleTwo")
|
||||
two:SetPoint("BOTTOMLEFT", 0, 0); two:SetWidth(800); two:SetHeight(600)
|
||||
two:SetScale(2)
|
||||
kid = CreateFrame("Frame", "ScaleKid", one)
|
||||
kid:SetPoint("BOTTOMLEFT", one, "BOTTOMLEFT", 10, 20); kid:SetWidth(100); kid:SetHeight(50)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let at_one: (f32, f32, f32) = s
|
||||
.eval("return ScaleKid:GetLeft(), ScaleKid:GetBottom(), ScaleKid:GetWidth()")
|
||||
.unwrap();
|
||||
|
||||
// The whole point: the write between the two reads is the reparent, and the read after it must
|
||||
// see it. Scale 2 doubles the offsets and the span in screen units — and `GetWidth` reports in
|
||||
// the frame's OWN scaled space, so the width reads back unchanged while the edges move.
|
||||
s.run("kid:SetParent(two)").unwrap();
|
||||
s.resolve();
|
||||
let at_two: (f32, f32, f32) = s
|
||||
.eval("return ScaleKid:GetLeft(), ScaleKid:GetBottom(), ScaleKid:GetWidth()")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(at_one, (10.0, 20.0, 100.0), "seated in the unscaled parent");
|
||||
assert_eq!(
|
||||
at_two,
|
||||
(10.0, 20.0, 100.0),
|
||||
"own-space coordinates are scale-relative, so these do not move"
|
||||
);
|
||||
// …but the SCREEN rect does. Read it off the extraction, which is in screen units.
|
||||
let widths: Vec<f32> = s
|
||||
.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match q.target {
|
||||
crate::order::ZTarget::Frame(_) => q.rect.map(|r| r.right - r.left),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
widths.iter().any(|w| (w - 200.0).abs() < 1e-3),
|
||||
"the reparented child should be 100 x scale 2 = 200 screen units wide; got {widths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,10 +181,18 @@ impl Toc {
|
||||
/// falls back to this byte whenever the characters disagree, and whenever *none* of them has
|
||||
/// an opinion at all — which is every addon on a fresh install.
|
||||
///
|
||||
/// **A manifest that does not write the line is enabled**, which is what makes a folder
|
||||
/// dropped into `AddOns/` just work. That is the two literals' fall-through, not a guess at
|
||||
/// the record's initial byte — a value that is neither `enabled` nor `disabled` reads the
|
||||
/// same way, because neither compare matches it and the field keeps whatever it had.
|
||||
/// **A manifest that does not write the line is enabled** — which is what makes a folder
|
||||
/// dropped into `AddOns/` just work, and is the record's initial byte, read at the bytes and
|
||||
/// not inferred from the two literals' fall-through (wow-re
|
||||
/// `addon-defaultstate-and-node-set.md`, decision 2316). The ctor `0x520550` seeds this one
|
||||
/// field to 1 explicitly — `0x5205b9 mov [esi+0x2b],al` with `eax = 1`, where its five
|
||||
/// neighbours take `bl = 0` — over an allocation that does zero-fill, which is exactly what
|
||||
/// made "the ctor zeroes it" read as true.
|
||||
///
|
||||
/// Two consequences this function depends on, both verified there: a value matching **neither**
|
||||
/// literal leaves the 1 (`0x51d21a jne`, no store — there is no "unrecognised means
|
||||
/// disabled"), and a duplicated directive is **last-wins**, which is [`Self::directive`]'s own
|
||||
/// rule.
|
||||
pub fn default_state(&self) -> bool {
|
||||
!self
|
||||
.directive("DefaultState")
|
||||
@@ -224,6 +232,37 @@ mod tests {
|
||||
assert_eq!(toc.directives.len(), 4);
|
||||
}
|
||||
|
||||
/// **`## DefaultState:` and its three non-obvious answers**, all byte-verified (wow-re
|
||||
/// `addon-defaultstate-and-node-set.md`): an absent line is *enabled* because the record's
|
||||
/// ctor seeds `[rec+0x2b]` to 1, a value matching neither literal leaves that 1 rather than
|
||||
/// meaning disabled, and a duplicated line is last-wins.
|
||||
///
|
||||
/// Only `disabled` disables, in other words — which is the same asymmetry `AddOns.txt`'s own
|
||||
/// value grammar has, and worth a falsifier because "unrecognised means off" is the reading
|
||||
/// anyone would write by hand.
|
||||
#[test]
|
||||
fn default_state_is_enabled_unless_the_manifest_says_disabled() {
|
||||
let d = |body: &str| Toc::parse(body).default_state();
|
||||
assert!(d("## Interface: 11200\n"), "absent: the ctor's own 1");
|
||||
assert!(d("## DefaultState: enabled\n"));
|
||||
assert!(!d("## DefaultState: disabled\n"));
|
||||
assert!(
|
||||
!d("## DefaultState: DISABLED \n"),
|
||||
"trimmed, case-folded"
|
||||
);
|
||||
assert!(
|
||||
d("## DefaultState: maybe\n"),
|
||||
"neither literal: no store, the 1 stands"
|
||||
);
|
||||
assert!(
|
||||
d("## DefaultState:\n"),
|
||||
"empty value is not `disabled` either"
|
||||
);
|
||||
// Last-wins, the reference's hash insert — not first-wins, and not an append.
|
||||
assert!(!d("## DefaultState: enabled\n## DefaultState: disabled\n"));
|
||||
assert!(d("## DefaultState: disabled\n## DefaultState: enabled\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_1_12_shape() {
|
||||
let toc = Toc::parse(
|
||||
|
||||
Reference in New Issue
Block a user