the hover tax is pinned and cut, and a spell-less macro is a button

- a tooltip change costs ~1.3 ms — five whole-interface recomputes, each working as designed: the quad re-convert at 474 µs, the Lua tick at 287, the layout resolve at 200, bevy's `Assets<Mesh>` tax at ~270 and the mesh rebuild at 146 — pinned with new hover probes (a jitter dial, a `WOW_PROBE_HOVER_DUTY` duty cycle so run drift cancels, an `AssetChanged` tax meter), and the layout preamble's two whole-roster scans become lists maintained at the writer (52–83 µs → 1–11)
- the splice compared this frame's entries against last frame's by index, so one inserted highlight shifted the 160 entries behind it and read as "everything changed"; it now aligns on `z` — an identity, not a position — and a `Text` entry that writes only quads became spliceable, so a tooltip change re-converts 22 entries instead of 520 (`convert` +439 µs → +107), with `WOW_UI_SPLICE_VERIFY` running the full conversion behind every spliced frame: 0 mismatches over 1,078
- a spell-less macro is a usable action button, not the 0.4 grey — the reference's usable compute has a dedicated leg answering usable for the bound-spell zero, cross-checked at the bytes in the reference's disassembly — and the button's Name line is written unconditionally from `GetActionText` as the reference does, so the macro's name sits under its icon and a `/cast` of a spell the character does not know now greys, which is also 1.12's behaviour (B340)
- a capture bounds its run in wall-clock seconds, because a frame cap is no cap at the ~1 fps a starved `nextDrawable` leaves — 1800 frames is thirty minutes there — and the teardown backstop runs on every run as a liveness guarantee, carrying the exit code the run asked for; the Multisampling row reads Off / 2x / 4x, since "1x" contradicts the no-multisampling byte both of the reference's enumerators normalise to and the bit-depth prefix was identical on every row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:13:58 +07:00
co-authored by Claude Fable 5
parent bf7201f4d3
commit a40cc4a905
26 changed files with 1599 additions and 257 deletions
+18 -4
View File
@@ -443,6 +443,7 @@
button.flash = getglobal(n .. "Flash")
button.border = getglobal(n .. "Border")
button.cooldown = getglobal(n .. "Cooldown")
button.name = getglobal(n .. "Name")
button.flashing = button.flashing or 0
button.flashtime = button.flashtime or 0
-- The grid count, same posture: a foreign button reaching ActionButton_HideGrid with
@@ -521,6 +522,7 @@
button.flash = getglobal(button:GetName() .. "Flash")
button.border = getglobal(button:GetName() .. "Border")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
button.name = getglobal(button:GetName() .. "Name")
-- Ref ActionButton_OnLoad:104-106: the flash/range state the OnUpdate loop drives.
button.flashing = 0
button.flashtime = 0
@@ -944,6 +946,15 @@
else
button.border:Hide()
end
-- Ref ActionButton_Update:236-238, "Update Macro Text": the Name line under the icon,
-- written on EVERY update, occupied or not — `GetActionText` answers a MACRO slot's
-- macro name and nil for anything else, and a nil clears the line, so an emptied or
-- re-filled slot loses the old name through this same write. Guarded only because an
-- adopted foreign button may inherit a template that declares no `$parentName`.
-- (B340: the line was declared in the template and never set — decision 1636.)
if button.name then
button.name:SetText(GetActionText(action))
end
end
function BenillaActionBar_OnLoad(frame)
@@ -1610,7 +1621,9 @@
widget playing the same machine); NormalTexture = the 66x66 UI-Quickslot2 ring centered at
(0,-1) (l.74-85); PushedTexture UI-Quickslot-Depress (l.86); HighlightTexture
ButtonHilight-Square ADD (l.87); CheckedTexture CheckButtonHilight ADD (l.88 — the
IsCurrentAction/IsAutoRepeatAction ring). Only Name (macros) stays OUT.
IsCurrentAction/IsAutoRepeatAction ring); Name (the macro-name line, l.34-46) on ARTWORK,
set from `GetActionText` at the tail of every update as the ref's `ActionButton_Update`
does (decision 1636).
-->
<!-- **The reference's own two-level split, which ours used to conflate into one template.**
`ActionButtonTemplate` (ref ActionButtonTemplate.xml:3) is REGIONS ONLY and carries no
@@ -1644,9 +1657,10 @@
</Anchors>
</FontString>
<!-- ref ActionButtonTemplate.xml's `$parentName` — the macro-name line along the
button's bottom edge. Empty by default, so it draws nothing until something
sets it; declared because it is part of the template's published contract and
an addon indexing `<button>Name` would otherwise get nil. -->
button's bottom edge. `BenillaActionButton_Update` writes it from
`GetActionText` on every update (a MACRO slot's macro name, nil — cleared —
for any other kind); it is also part of the template's published contract, so
an addon indexing `<button>Name` gets the region. -->
<FontString name="$parentName" inherits="GameFontHighlightSmallOutline">
<Size><AbsDimension x="36" y="10"/></Size>
<Anchors>
+30 -7
View File
@@ -2351,15 +2351,38 @@
local stops = {}
local fmt = { GetMultisampleFormats() }
-- Three varargs per entry, the walk OptionsFrame.lua
-- l.354 does (`for i=1, arg.n, 3`). The text is
-- MULTISAMPLING_FORMAT_STRING verbatim, inlined
-- rather than read off the global so the row does
-- not depend on GlobalStrings being loaded.
-- l.354 does (`for i=1, arg.n, 3`) -- and then we
-- use ONE of the three.
--
-- 1.12 formats all three (MULTISAMPLING_FORMAT_
-- STRING = "%d-bit color %d-bit depth %dx
-- multisample") because in 2004 they VARIED: its
-- D3D CheckDeviceMultiSampleType sweep really did
-- enumerate 16-bit and 32-bit colour modes, so the
-- colour and depth figures were part of the
-- choice. Ours cannot vary -- every entry carries
-- the one pair `view::dropdown_bit_depths` returns,
-- the swapchain's format and Depth32Float, fixed
-- in our code and not by luck of hardware. Quoting
-- them on every row would reproduce the FORMAT of
-- a fact we no longer have, and it overflowed the
-- capsule doing it (the director's 08-27 shot).
-- The full triple is on the record in 1632; the
-- row shows what a player is actually choosing.
--
-- "Off", not "1x": 1 is the encoding of NO
-- multisampling, not one sample of it -- VERIFIED
-- at 0x59989b (D3D leaves MultiSampleType
-- D3DMULTISAMPLE_NONE) and 0x59de35 (GL never
-- emits the SAMPLE_BUFFERS/SAMPLES pair). Short
-- values are this page's own grammar too: the
-- label carries the noun and the capsule carries
-- the value ("Environment Detail [ High ]").
for i = 1, table.getn(fmt), 3 do
local n = fmt[i + 2]
table.insert(stops, {
text = format("%d-bit color %d-bit depth %dx multisample",
fmt[i], fmt[i + 1], fmt[i + 2]),
value = tostring(fmt[i + 2]),
text = (n > 1) and (n .. "x") or "Off",
value = tostring(n),
})
end
OptionsDropdownRow_Setup(self, stops)
+145 -43
View File
@@ -59,7 +59,7 @@
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
use std::path::Path;
use std::time::Duration;
use std::time::{Duration, Instant};
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Capturing, Screenshot, ScreenshotCaptured};
@@ -324,6 +324,37 @@ fn stable_frames() -> u32 {
/// loudly. Deliberately generous: reaching it means the shot is not reproducible.
const BUILD_CAP_FRAMES: u32 = 1800;
/// Wall-clock ceiling on a whole capture run, deadline exceeded → `AppExit::error()`.
///
/// **Every other bound in this harness counts FRAMES, and that is only a bound while frames keep
/// arriving.** On 2026-08-26 macOS stopped granting drawables to our window — `-[CAMetalLayer
/// nextDrawable]` parked on its own ~1 s internal timeout, over and over, verified in the stall
/// sampler's own captures (758 of ~1000 samples in `semaphore_timedwait_trap` beneath
/// `CAMetalLayerPrivateNextDrawableLocked`, with `frame hitch: 1005..1019 ms` repeating in the log).
/// At that rate [`BUILD_CAP_FRAMES`] is not 30 seconds of patience, it is **thirty minutes**, and
/// every agent-driven capture that session read as a dead terminal with no error and no clue.
///
/// So the ceiling that matters is the one measured in the units the failure is measured in. It is
/// not a retry, a fallback or a heuristic: a capture either produces its image or fails, and this is
/// the line that makes the second one *happen* instead of hanging. The message it prints carries the
/// observed frame rate, because "47 frames in 300 s" is the diagnosis and a bare timeout is not.
///
/// The starvation itself is macOS's, not ours — the compositor stops recycling presented drawables
/// for a window it is not compositing, and nothing in-process can hand them back. We bound our own
/// instrument; we do not fight the window server. Decision 1637.
const DEADLINE_SECS: u64 = 300;
/// The effective wall-clock ceiling — `$WOW_CAPTURE_DEADLINE=<secs>`, `0` to disable. A healthy
/// capture is seconds (`ui-bag` 7 s, a settled world scenario ~10 s), so 300 s is ~30× headroom and
/// cannot fire on a run that is merely slow.
fn capture_deadline() -> Option<Duration> {
let secs = std::env::var("WOW_CAPTURE_DEADLINE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEADLINE_SECS);
(secs > 0).then(|| Duration::from_secs(secs))
}
/// Steps of [`CAPTURE_FRAME_DT`] the sims run, clock released, once the scene is built and
/// quiescent — the shot's *sim age*. 150 = 2.5 s: past the 2 s spawn appear-fade
/// (`model_fade::APPEAR_FADE_SECS`) and long past a flame pool's particle lifetime, so the scene is
@@ -437,8 +468,20 @@ struct CaptureCtx {
/// frame-time stats + scene counts and exit. The repeatable perf instrument: same scenario, same
/// settle, numbers instead of pixels. 0 = normal capture.
probe_frames: u32,
/// The frozen clock is in force ([`CAPTURE_FRAME_DT`]) — always, except under a perf probe,
/// whose entire measurement *is* the real frame cost.
/// The frozen clock is still in force ([`CAPTURE_FRAME_DT`]). True for the whole of every run
/// that ends in a screenshot; a perf probe **starts** frozen and drops this the moment the scene
/// is built and aged, because its entire measurement *is* the real frame cost.
///
/// It used to be `probe_frames == 0`, decided once at build — i.e. a probe ran its whole
/// `Building` phase on a live clock. That silently broke when 0815 replaced the settle proxies
/// with the image-stability gate: on a live clock the sky drifts and the flames burn, so the
/// image *cannot* stop changing and `watch.stable` never leaves 0. Every probe since has spent
/// the full [`BUILD_CAP_FRAMES`] — 1800 frames, ~30 s, and 1800 full-resolution readbacks of a
/// stability test that could not pass — and then printed a warning about a shot it never takes.
/// Worse for a *perf* instrument: the scene it measured was however old 1800 real frames made
/// it, which is a different age on every machine. Frozen build → deterministic [`age_frames`] →
/// release is the same three steps a capture takes, and it is what makes the probe repeatable.
/// Decision 1637.
frozen_clock: bool,
/// `$WOW_RESIZE=WxH` already applied (once, at first settle) — see the `Building` arm.
resized: bool,
@@ -447,6 +490,17 @@ struct CaptureCtx {
/// Process CPU seconds at the first sampled frame — the window baseline for the probe line's
/// `cpu_ms`/`cpu_pct` (the load-robust metric, decision 0711; the scenario probe lacked it).
probe_cpu_start: Option<f64>,
/// Wall-clock start of the run, and how long it may take — see [`capture_deadline`]. A real
/// [`Instant`], never `Time<Real>`: under the frozen clock `Time<Real>` is itself manual
/// ([`CAPTURE_FRAME_DT`] per frame), so a harness that timed itself by it would measure the
/// very frame count it is trying not to trust.
started: Instant,
deadline: Option<Duration>,
/// Frames [`drive_capture`] has run, all phases — the denominator of the rate the deadline
/// message reports.
frames: u32,
/// The deadline already fired; `AppExit` is written and no phase advances again.
bailed: bool,
}
/// `$WOW_RESIZE=WxH` — resize the window to this (logical px) once the image first settles,
@@ -714,14 +768,14 @@ impl Plugin for CapturePlugin {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
// The frozen capture clock ([`CAPTURE_FRAME_DT`]) — never under a perf probe, which reads
// `time.delta_secs()` AS its measurement and would report a flawless constant 16.67 ms for
// any scene, however slow.
let frozen_clock = probe_frames == 0;
if frozen_clock {
app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT))
.add_systems(Startup, hold_clock);
}
// The frozen capture clock ([`CAPTURE_FRAME_DT`]) — now for every run, probe included, and
// released at a phase boundary rather than never installed (see `CaptureCtx::frozen_clock`).
// A probe still measures a live clock: `drive_capture` restores
// `TimeUpdateStrategy::Automatic` on the way into `ProbeWarmup`, before a single frame is
// sampled, because `time.delta_secs()` under a manual strategy would report a flawless
// constant 16.67 ms for any scene, however slow.
app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT))
.add_systems(Startup, hold_clock);
app.insert_resource(CaptureMode)
.init_resource::<FrameWatch>()
.insert_resource(CaptureCtx {
@@ -731,10 +785,14 @@ impl Plugin for CapturePlugin {
phase: Phase::Building(0),
ui_seeded: false,
probe_frames,
frozen_clock,
frozen_clock: true,
resized: false,
probe_samples: Vec::new(),
probe_cpu_start: None,
started: Instant::now(),
deadline: capture_deadline(),
frames: 0,
bailed: false,
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
// Before the UnitFeed pass: the seed stands in for wire data that in live play
@@ -837,7 +895,8 @@ fn drive_capture(
capturing: Query<(), (With<Capturing>, Without<StabilityShot>)>,
mut commands: Commands,
mut exit: MessageWriter<AppExit>,
time: Res<Time<bevy::time::Real>>,
// `ResMut` for one reason: re-anchoring the clock at the probe's release — see `Phase::Aging`.
mut time: ResMut<Time<bevy::time::Real>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&benilla_world::particles::ParticleEmitter>,
parts: Query<&ViewVisibility, With<benilla_world::model_render::ModelPart>>,
@@ -852,7 +911,35 @@ fn drive_capture(
// instead; decision 0789 says why, and what learning it cost.
game_time: Res<Time>,
mut clock: ResMut<Time<Virtual>>,
// Switched to `Automatic` at the probe's release point — see `Phase::Aging`.
mut time_strategy: ResMut<TimeUpdateStrategy>,
) {
// The wall-clock ceiling, checked before anything else and in every phase — `Building` is
// where the drawable starvation was caught, but `FxAging` waits on a fixture that may never
// attach and `Saving` on a readback that may never land, and one deadline covers all three
// where three per-phase frame caps would not ([`capture_deadline`]).
if ctx.bailed {
return;
}
ctx.frames += 1;
if let Some(limit) = ctx.deadline {
let elapsed = ctx.started.elapsed();
if elapsed > limit {
ctx.bailed = true;
error!(
"capture: DEADLINE — {} ran {:.0}s without finishing ({} frames, {:.1} fps). \
The harness bounds itself in frames, so this is what a machine that stopped \
granting frames looks like; at 1 fps the frame caps are half an hour. No \
image written. ($WOW_CAPTURE_DEADLINE=<secs>, 0 disables.)",
ctx.name,
elapsed.as_secs_f32(),
ctx.frames,
ctx.frames as f32 / elapsed.as_secs_f32(),
);
exit.write(AppExit::error());
return;
}
}
// Both fixture viewers (fxview / waterfx) share the build→arm→age→shoot flow; only the
// requested age differs.
let fixture_age = fx_req
@@ -916,40 +1003,17 @@ fn drive_capture(
state.armed = true; // scene ready — the fixture spawns now, age clock clean
}
Phase::FxAging
} else if ctx.probe_frames > 0 {
// Probe: uncap presentation so we measure true frame cost, not the vsync ceiling.
// `$WOW_PROBE_VSYNC=1` keeps vsync ON instead — the probe then measures the PRESENT
// ceiling itself (what fps the display sync actually grants this window), the
// instrument for "what is the vsync cap right now".
let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1");
if !keep_vsync {
if let Ok(mut w) = windows.single_mut() {
w.present_mode = probe_uncap_mode();
}
}
info!(
"probe: image settled; vsync {}, warming {PROBE_WARMUP_FRAMES} frames",
if keep_vsync { "KEPT ON" } else { "off" }
);
Phase::ProbeWarmup(0)
} else if ctx.frozen_clock {
} else {
// Clock released here: the sims now run exactly `age_frames()` fixed steps, so the
// shot's sim age is the same on any machine (decision 0723).
// shot's sim age is the same on any machine (decision 0723) — and a probe takes the
// same road, so the scene it measures is that same fixed age instead of "however
// old 1800 real frames left it" (`CaptureCtx::frozen_clock`).
info!(
"capture: image settled after {} frames, aging {}",
n + 1,
age_frames()
);
Phase::Aging(0)
} else {
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
info!("capture: image settled, writing {}", ctx.out);
Phase::Saving {
frames: 0,
seen: false,
}
}
}
Phase::FxAging => {
@@ -980,7 +1044,47 @@ fn drive_capture(
// count-quiescence gate it shared a threshold with had already passed. What replaced it
// is upstream and stronger: the image itself stopped changing before the clock was
// released, so a straggler that would have mattered was already waited out (0815).
if n + 1 >= age_frames() {
if n + 1 < age_frames() {
Phase::Aging(n + 1)
} else if ctx.probe_frames > 0 {
// Scene built and aged to a known duration; hand the clock back to real time
// BEFORE a single frame is sampled, or `Phase::Probing` would read the manual
// [`CAPTURE_FRAME_DT`] as its measurement and report a flawless 16.67 ms.
ctx.frozen_clock = false;
*time_strategy = TimeUpdateStrategy::Automatic;
// Re-anchor `Time<Real>` to NOW, or the first automatic frame is billed for the
// entire frozen phase. `update_with_duration` sets `last_update = last_update +
// dt` — a fictional instant that falls behind reality by exactly the wall-clock
// time spent frozen (~8 s here). The next `Automatic` tick does
// `Instant::now() - that`, which lands as one multi-second delta: `perf::stats`
// reported it as a phantom `frame hitch: 1060 ms`, and `update_virtual_time`
// clamped it to `max_delta` and stepped every sim a quarter-second at once — a
// spawn burst whose cost then landed in the samples (measured: p95 46-52 ms
// against 18 ms before).
//
// Called from `Last`, so the huge delta it writes is overwritten by the next
// frame's `time_system` before anything reads it, and it never reaches
// `Time<Virtual>` at all — `update_virtual_time` only runs inside `time_system`.
// What survives is the anchor: `last_update = now`, so frame one of the
// measurement is an ordinary frame.
time.update_with_instant(Instant::now());
// Uncap presentation so we measure true frame cost, not the vsync ceiling.
// `$WOW_PROBE_VSYNC=1` keeps vsync ON instead — the probe then measures the PRESENT
// ceiling itself (what fps the display sync actually grants this window), the
// instrument for "what is the vsync cap right now".
let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1");
if !keep_vsync {
if let Ok(mut w) = windows.single_mut() {
w.present_mode = probe_uncap_mode();
}
}
info!(
"probe: scene aged {} frames; vsync {}, warming {PROBE_WARMUP_FRAMES} frames",
age_frames(),
if keep_vsync { "KEPT ON" } else { "off" }
);
Phase::ProbeWarmup(0)
} else {
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
@@ -989,8 +1093,6 @@ fn drive_capture(
frames: 0,
seen: false,
}
} else {
Phase::Aging(n + 1)
}
}
Phase::ProbeWarmup(n) => {
+81 -2
View File
@@ -391,6 +391,22 @@ fn fire_probe_lua(
/// `WOW_PROBE_HOVER_STEP` seconds per frame-to-frame step, default 0.25, looping until exit) —
/// the cursor crosses each named frame's centre through the REAL pointer path, pressing nothing.
///
/// **A leg that does not ALTERNATE cannot be read.** `WOW_PROBE_HOVER_DUTY=<on>:<off>` sweeps for
/// `on` seconds, parks the pointer over nothing for `off`, and repeats. Compare-the-two-halves
/// designs that put all the parked frames at the start and all the swept frames after read the
/// run's own drift as the effect: three legs of the same gesture, same binary, gave within-run
/// `cpu_ms` deltas of +2.03, +1.02 and −0.61 ms while their per-phase µs columns agreed to 3%
/// (decision 1634). Alternating pools both regimes across the same minutes, so drift cancels
/// instead of landing on one side.
///
/// **`WOW_PROBE_HOVER_STEP` is the dial that decides what is being measured, and the default is
/// not the director's gesture.** A hand spamming hovers moves the mouse *every frame*; at the
/// 0.25 s default only 4 frames in 60 carry a pointer move, which divides whatever a move costs by
/// fifteen and reads as "hovering is free" (it did, three times, before this note existed). Set it
/// to ~0.016 to sweep at frame rate. `WOW_PROBE_HOVER_JITTER=<px>` then splits the two halves the
/// sweep otherwise fuses: with one name and a jitter, the pointer moves every frame while the
/// hovered frame and its tooltip stay put.
///
/// Built because a hover-cost pin was taken with a Lua driver that called `GameTooltip:SetOwner`
/// directly, and that driver is not the gesture: it never moves `model.mouseover`, so it skips the
/// hit test, `OnEnter`/`OnLeave`, the button's state textures and highlight, and whatever the
@@ -415,13 +431,27 @@ impl Plugin for ProbeHoverPlugin {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.25);
let jitter = std::env::var("WOW_PROBE_HOVER_JITTER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
let duty = std::env::var("WOW_PROBE_HOVER_DUTY")
.ok()
.and_then(|v| {
v.split_once(':')
.map(|(a, b)| (a.to_string(), b.to_string()))
})
.and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?)));
app.insert_resource(ProbeHover {
names,
at,
step,
jitter,
duty,
i: 0,
next: 0.0,
announced: false,
parked: false,
})
.add_systems(Update, fire_probe_hover);
}
@@ -433,11 +463,24 @@ struct ProbeHover {
names: Vec<String>,
at: f32,
step: f32,
/// `WOW_PROBE_HOVER_JITTER=<px>` — nudge each step off the frame's centre by up to this many
/// pixels, cycling through a small square. Zero (the default) parks the cursor exactly on the
/// centre, so a ONE-NAME sweep issues the same coordinates forever and the pointer never moves.
jitter: f32,
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>` — alternate `on` seconds of sweeping with `off` seconds
/// parked off every frame, forever. See the plugin doc for why a leg that does not alternate
/// cannot be read at this resolution.
duty: Option<(f32, f32)>,
i: usize,
next: f32,
announced: bool,
/// Which half of the duty cycle we are in (meaningless when `duty` is `None`).
parked: bool,
}
/// Where the pointer goes during a duty cycle's parked half: the top-left corner, over nothing.
const HOVER_PARK_AT: (f32, f32) = (2.0, 2.0);
/// Move the cursor onto the next named frame's centre. Loops forever, so a run of any length is a
/// steady sweep — the population the recorder splits on.
fn fire_probe_hover(
@@ -455,8 +498,22 @@ fn fire_probe_hover(
return;
}
let Some(mut script) = script else { return };
probe.next = now + probe.step;
synthetic.0 = true;
// The duty cycle, if armed: which half of the current period is this, and did it just flip?
if let Some((on, off)) = probe.duty {
let period = (on + off).max(1e-3);
let parked = (now - probe.at).rem_euclid(period) >= on;
let flipped = parked != probe.parked;
probe.parked = parked;
if parked {
probe.next = now + probe.step;
if flipped {
script.mouse_move(HOVER_PARK_AT.0, HOVER_PARK_AT.1);
}
return;
}
}
probe.next = now + probe.step;
let name = probe.names[probe.i % probe.names.len()].clone();
probe.i += 1;
match frame_centre(&script, &name) {
@@ -469,7 +526,8 @@ fn fire_probe_hover(
probe.step
);
}
script.mouse_move(x, y);
let (dx, dy) = jitter_offset(probe.jitter, probe.i);
script.mouse_move(x + dx, y + dy);
}
// Named but unresolved is worth saying once per pass rather than silently sweeping air —
// a sweep over frames that do not exist reads as "hovering is free".
@@ -477,6 +535,27 @@ fn fire_probe_hover(
}
}
/// A deterministic offset inside a `j`-pixel square, cycling with the step index.
///
/// It exists to separate the two costs a hover sweep otherwise fuses: **the pointer moved** (the
/// hit test re-runs) and **the hovered frame changed** (the tooltip is torn down and rebuilt). A
/// one-name sweep with a jitter moves the pointer every step while the hovered frame — and the
/// tooltip on it — stay put, which is the first of the two on its own.
fn jitter_offset(j: f32, i: usize) -> (f32, f32) {
if j <= 0.0 {
return (0.0, 0.0);
}
// A 4-phase square walk: (+,+) → (-,+) → (-,-) → (+,-). Every step is a real move, and the
// cursor never leaves a `j`-pixel box around the centre.
let (sx, sy) = match i % 4 {
0 => (1.0, 1.0),
1 => (-1.0, 1.0),
2 => (-1.0, -1.0),
_ => (1.0, -1.0),
};
(sx * j, sy * j)
}
pub(crate) struct ProbeDragPlugin;
impl Plugin for ProbeDragPlugin {
+14 -3
View File
@@ -90,7 +90,7 @@ impl Plugin for HoverLogPlugin {
"frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\
diff_us,quads,solves,derives,skipped,measured,measured_texts,tip_owner,tip_lines,\
tip_first_line,spliced,mesh_us,mesh_sort_us,mesh_split_us,mesh_write_us,\
mesh_quads,mesh_runs,mesh_rewrites"
mesh_quads,mesh_runs,mesh_rewrites,dropped"
);
info!("hover log: recording every frame to {path}");
app.insert_resource(Recorder {
@@ -180,8 +180,15 @@ fn record(
);
let _ = writeln!(
rec.out,
",{},{},{},{},{},{},{}",
mesh.total, mesh.sort, mesh.split, mesh.write, mesh.quads, mesh.runs, mesh.rewrites
",{},{},{},{},{},{},{},{}",
mesh.total,
mesh.sort,
mesh.split,
mesh.write,
mesh.quads,
mesh.runs,
mesh.rewrites,
c.dropped,
);
rec.rows.push(Row {
frame_ms,
@@ -381,6 +388,10 @@ fn parse_row(line: &str) -> Option<Row> {
skipped: num(11)? != 0.0,
// Appended column (absent in pre-splice recordings — those read back as 0).
spliced: fields.get(17).and_then(|f| f.parse().ok()).unwrap_or(0),
// Appended after the mesh block for the same reason `spliced` was appended after
// the tip block: a new column goes on the END so every recording made before it
// still reads (decision 1638).
dropped: fields.get(25).and_then(|f| f.parse().ok()).unwrap_or(0),
},
mesh,
tip: if owner.is_empty() {
+37
View File
@@ -512,3 +512,40 @@ pub(super) fn row_bloat(mut commands: Commands, mut done: Local<bool>, source: B
);
*done = true;
}
/// `WOW_MESH_TOUCH=<secs>` — from `secs` onward, mark ONE scratch [`Mesh`] asset modified every
/// frame, and nothing else.
///
/// **The tax meter for [`bevy::asset::AssetChanged`].** Bevy 0.18's `AssetChanged<Mesh3d>` fast
/// path is all-or-nothing: one `Assets<Mesh>` modification — of *any* mesh, including a `Mesh2d`
/// UI batch no 3D row will ever reference — arms `mark_3d_meshes_as_changed_if_their_assets_changed`
/// plus one `check_entities_needing_specialization::<M>` walk per registered material type, each
/// over every `Mesh3d` row in the scene. 1361's per-slot skip gate and 1463's pan gate both exist
/// to keep that disarmed, and both are gates on the *UI's* writes; this measures the price of a
/// single arming with the UI's own work subtracted out, which no gate-side experiment can.
///
/// One run, two regimes, so the reading is a WITHIN-run paired delta: run-to-run cpu variance on
/// this machine is ±1 ms (1157), which is the size of the thing being measured.
pub(super) fn mesh_touch(
mut meshes: ResMut<Assets<Mesh>>,
time: Res<bevy::time::Time<bevy::time::Real>>,
at: Res<MeshTouchAt>,
mut scratch: Local<Option<Handle<Mesh>>>,
) {
if time.elapsed_secs() < at.0 {
return;
}
let handle = scratch.get_or_insert_with(|| {
meshes.add(Mesh::new(
bevy::mesh::PrimitiveTopology::TriangleList,
bevy::asset::RenderAssetUsages::default(),
))
});
// `get_mut` is the whole point: it writes `AssetEvent::Modified` for this id, which bumps the
// global changed tick every `AssetChanged` filter reads. The mesh itself stays empty.
let _ = meshes.get_mut(&*handle);
}
/// When [`mesh_touch`] starts touching (seconds of `Time<Real>`).
#[derive(Resource)]
pub(super) struct MeshTouchAt(pub f32);
+10
View File
@@ -138,6 +138,16 @@ impl Plugin for PerfPlugin {
{
app.add_systems(Update, census::row_bloat);
}
// `WOW_MESH_TOUCH=<secs>` — the `AssetChanged` tax meter (see the system's doc): from
// `secs` onward, one scratch `Assets<Mesh>` modification per frame and nothing else, so
// the arming's price is read as a within-run paired delta with the UI's own work removed.
if let Some(at) = std::env::var("WOW_MESH_TOUCH")
.ok()
.and_then(|v| v.parse::<f32>().ok())
{
app.insert_resource(census::MeshTouchAt(at));
app.add_systems(Update, census::mesh_touch);
}
// `WOW_CPU_CENSUS=<at>:<secs>` — the per-thread CPU census (see its module doc): where
// the pill's cpu-ms goes, thread by thread, summing exactly to the process total.
#[cfg(target_os = "macos")]
+47 -17
View File
@@ -8,16 +8,25 @@
//! the hang may own. Samples land in `benilla-config/Diagnostics/` and the path prints on stderr
//! (the tracing subscriber may already be gone during teardown).
//!
//! After a teardown sample, an [`EXIT_KILL_MS`] backstop `_exit(0)` ends the wedged process —
//! After a teardown sample, an [`EXIT_KILL_MS`] backstop `_exit` ends the wedged process —
//! diagnosis first, then the force-quit the director would otherwise perform by hand.
//! `libc::_exit`, never `process::exit`: it skips the atexit handlers.
//! `libc::_exit`, never `process::exit`: it skips the atexit handlers. It exits with the code
//! [`AppExit`] asked for, not a blanket 0 — a capture that already failed its own deadline must
//! not be laundered into a success by the thing that unwedges it.
//!
//! **The backstop is not a diagnostic, and it runs where the samples cannot.** A hermetic capture
//! run has no `benilla-config/` to write into ([`crate::local_state::home`] returns `None` under
//! `$WOW_CAPTURE`), and for one day in 1631 that turned the whole plugin off for exactly the runs
//! that most need a liveness guarantee — an agent-driven run with nobody watching it. So the split
//! is by concern, not by run: the watchdog thread and its `_exit` always run; the sample *file* is
//! written only where there is a folder that may hold one. Decision 1637.
//!
//! Verification is end-to-end via two injectors (used by the 0713 probe rounds, kept as standing
//! test affordances): `WOW_STALL_INJECT=<at_secs>:<ms>` sleeps the main thread mid-run, and
//! `WOW_TEARDOWN_INJECT=<ms>` wedges World drop via a resource whose `Drop` sleeps — the
//! director's beachball, synthetically.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
@@ -33,6 +42,10 @@ static HEARTBEAT_MS: AtomicU64 = AtomicU64::new(0);
/// Latched the frame `AppExit` is written: switches the watchdog to the teardown threshold
/// and arms the post-sample `_exit` backstop.
static EXITING: AtomicBool = AtomicBool::new(false);
/// The status `AppExit` asked to exit with, latched alongside [`EXITING`] so the backstop can
/// honour it. A wedged teardown must not turn a failed run into a passing one — which is exactly
/// what a blanket `_exit(0)` would do to the capture deadline (`capture::deadline`).
static EXIT_CODE: AtomicU8 = AtomicU8::new(0);
/// In-frame staleness that means a real stall (ms) — above the ~250 ms loading-screen
/// hitches, below the ~1030 ms stall class it exists to catch.
@@ -58,35 +71,48 @@ fn since_start_ms() -> u64 {
/// `Last`-schedule heartbeat: stamp the clock; latch [`EXITING`] the frame the app decides
/// to quit (no later frame will run to unlatch anything).
fn beat(mut exits: MessageReader<AppExit>) {
if exits.read().next().is_some() {
if let Some(exit) = exits.read().next() {
if let AppExit::Error(code) = exit {
EXIT_CODE.store(code.get(), Ordering::SeqCst);
}
EXITING.store(true, Ordering::SeqCst);
}
HEARTBEAT_MS.store(since_start_ms().max(1), Ordering::SeqCst);
}
pub(super) fn plugin(app: &mut App) {
// The injectors are armed FIRST, deliberately: they are how the wedge is reproduced, and the
// one A/B this instrument exists to support is "same wedge, watchdog off — does the process
// still hang?". Registered after the `WOW_STALL_SAMPLE=0` gate, as they were until 1637, that
// control silently disarms the wedge too and comes back a clean exit 0, which reads as "the
// watchdog was never load-bearing" — the exact wrong conclusion. Arming a deliberate hang is
// never something the sampler's off-switch should be able to veto.
arm_injectors(app);
if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
return;
}
// Through `local_state`, never a hand-built path — the one-folder rule. This used to read
// `$HOME` and write `~/Library/Logs/benilla/`; see `local_state::diagnostics_dir`. `None` means
// a hermetic capture run, which gets no sampler.
let Some(dir) = crate::local_state::diagnostics_dir() else {
return;
};
// NOT created here: an empty `Diagnostics/` on every single run would be this instrument
// advertising itself in the player's folder for the ~always-case where nothing ever stalls.
// The watchdog creates it at the moment it actually has a sample to write.
// `$HOME` and write `~/Library/Logs/benilla/`; see `local_state::diagnostics_dir`. `None` is a
// hermetic capture run, which gets **no sample file and the watchdog anyway** — see the header:
// 1631 returned here instead and cost every capture its teardown backstop.
//
// The folder is NOT created here: an empty `Diagnostics/` on every single run would be this
// instrument advertising itself in the player's folder for the ~always-case where nothing ever
// stalls. The watchdog creates it at the moment it actually has a sample to write.
let dir = crate::local_state::diagnostics_dir();
START
.set(Instant::now())
.expect("stall_sample plugin built twice");
app.add_systems(Last, beat);
std::thread::Builder::new()
.name("stall-sample".into())
.spawn(move || watchdog(&dir))
.spawn(move || watchdog(dir))
.expect("spawn stall-sample watchdog");
}
// The injectors (doc above): a mid-run main-thread sleep, and a wedged World drop.
/// The injectors (module doc): a mid-run main-thread sleep, and a wedged World drop. Both are
/// standing test affordances, not diagnostics — see why they are armed ahead of the off-switch.
fn arm_injectors(app: &mut App) {
if let Some((at, ms)) = std::env::var("WOW_STALL_INJECT")
.ok()
.and_then(|v| v.split_once(':').map(|(a, m)| (a.to_owned(), m.to_owned())))
@@ -106,7 +132,7 @@ pub(super) fn plugin(app: &mut App) {
}
}
fn watchdog(dir: &std::path::Path) {
fn watchdog(dir: Option<std::path::PathBuf>) {
let pid = std::process::id().to_string();
let mut taken = 0u32;
let mut last_sample_ms = 0u64;
@@ -123,20 +149,24 @@ fn watchdog(dir: &std::path::Path) {
exit_seen_ms = now;
}
if exiting && now.saturating_sub(exit_seen_ms) > EXIT_KILL_MS {
let code = EXIT_CODE.load(Ordering::SeqCst);
eprintln!(
"stall-sample: teardown still wedged {} ms after AppExit — _exit(0)",
"stall-sample: teardown still wedged {} ms after AppExit — _exit({code})",
now.saturating_sub(exit_seen_ms)
);
use std::io::Write;
let _ = std::io::stdout().flush();
// SAFETY: terminates the process immediately; no locks, no atexit — the whole
// point, since the wedge may own both.
unsafe { libc::_exit(0) };
unsafe { libc::_exit(code as i32) };
}
let age = now.saturating_sub(hb);
if !exiting && now < STARTUP_GRACE_MS {
continue;
}
// `dir` is `None` on a hermetic capture: everything above still runs (the backstop is this
// thread's other, unconditional job), there is just nowhere to put a sample.
let Some(dir) = dir.as_deref() else { continue };
let threshold = if exiting { EXIT_STALL_MS } else { STALL_MS };
if age > threshold
&& taken < SAMPLE_CAP
+10 -5
View File
@@ -290,10 +290,8 @@ pub(super) fn feed_actions(
// The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK.
let template_epoch = items.template_epoch();
let macro_generation = script.macros_generation();
if !memory.resolved
|| actions.dirty
|| template_epoch != memory.template_epoch
|| macro_generation != memory.macro_generation
let macros_moved = macro_generation != memory.macro_generation;
if !memory.resolved || actions.dirty || template_epoch != memory.template_epoch || macros_moved
{
actions.dirty = false;
memory.resolved = true;
@@ -378,13 +376,20 @@ pub(super) fn feed_actions(
},
);
}
// A MACRO slot's observable is wider than its `ActionSlot`: the name line under the icon
// reads the macro table through `GetActionText` at repaint (decision 1636), so a rename —
// which moves the table and nothing in the slot value — must re-fire the slot exactly as
// a re-icon does, or the bar keeps the old name until an unrelated edit repaints it.
let changed: Vec<u32> = fresh
.keys()
.chain(memory.pushed.keys())
.copied()
.collect::<HashSet<_>>()
.into_iter()
.filter(|a| fresh.get(a) != memory.pushed.get(a))
.filter(|a| {
fresh.get(a) != memory.pushed.get(a)
|| (macros_moved && fresh.get(a).is_some_and(|s| s.kind == ACTION_KIND_MACRO))
})
.collect();
for &action in &changed {
script.set_action(action, fresh.get(&action).cloned());
@@ -352,4 +352,45 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
Some("Interface\\Icons\\Spell_Fire_FlameBolt"),
"the generation gate re-resolved the slot"
);
// RENAME the macro (decision 1636). The slot's value — texture, kind, id — is byte-identical
// after this, so a value diff alone would fire nothing and the bar would keep drawing the old
// name line until some unrelated edit repainted it. The feed must re-fire the slot anyway.
let events = |app: &mut App| {
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<i64>("return BENILLA_TEST_SLOT_EVENTS or 0")
.unwrap()
};
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(
r#"
local f = CreateFrame("Frame")
f:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
f:SetScript("OnEvent", function()
if arg1 == 1 then BENILLA_TEST_SLOT_EVENTS = (BENILLA_TEST_SLOT_EVENTS or 0) + 1 end
end)
EditMacro(1, "Shadowstep", nil)
"#,
)
.unwrap();
app.update();
assert_eq!(
events(&mut app),
1,
"a rename re-fires ACTIONBAR_SLOT_CHANGED for the slot"
);
assert_eq!(
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Option<String>>(&format!("return GetActionText({ACTION})"))
.unwrap()
.as_deref(),
Some("Shadowstep"),
"and the repaint reads the new name"
);
// A frame with nothing moved fires nothing more — the re-fire is gated on the table moving.
app.update();
assert_eq!(events(&mut app), 1);
}
+140 -20
View File
@@ -269,9 +269,26 @@ fn resolve_range(
Some((min, row.max + pad))
}
/// Resolve a slot's `(kind, id)` **through** a macro before any state is computed — the
/// reference's own shape, and the reason a macro button on the bar wears its spell's cooldown
/// swirl, usability tint, range colour and checked ring while showing its own icon.
/// What a slot *is* once the MACRO indirection is applied — the reference's slot→spell resolver
/// `0x4e5a50` plus the leg of the usable compute `0x4e5050` that reads its zero (decision 1636).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlotResolve {
/// The slot IS this `(kind, id)` from here down: a SPELL or ITEM slot, or a macro whose
/// bound spell is live (`[rec+0x564] > 0`).
Action(u8, u32),
/// A macro that exists but casts nothing (`[rec+0x564] == 0`): `0x4e5050`'s spell-less leg
/// (`0x4e50f4`–`0x4e516f`) answers **usable=1** off `0x4e5030` — "the slot's macro id is in
/// the macro table" — and computes nothing else: no cooldown, no range, no checked ring.
BareMacro,
/// Not usable and nothing to report: a `/cast` whose name did not resolve (`-1`, which the
/// spell path refuses at `0x4e518b: jl`), or a slot whose macro no longer exists (`0x4e5030`
/// is 0 and `IsActionActive 0x4e55f0` has no spell to find).
Dead,
}
/// Resolve a slot **through** a macro before any state is computed — the reference's own shape,
/// and the reason a macro button on the bar wears its spell's cooldown swirl, usability tint,
/// range colour and checked ring while showing its own icon.
///
/// Every `Is*Action`/`GetActionCooldown` binding routes through the one slot→spell resolver
/// `0x4e5a50`, whose MACRO arm resolves the macro record and returns `[rec+0x564]` as the slot's
@@ -279,17 +296,23 @@ fn resolve_range(
/// casts Fireball simply *is* the Fireball slot. `GetActionTexture` is the deliberate exception —
/// its macro arm keeps the macro's own icon (`super::feed`).
///
/// `None` = nothing to report (a macro bound to no spell; the reference's `[rec+0x564] == 0`).
/// Only the SPELL indirection is modelled: 1.12 has no `/use <item>` slash command, so no 1.12
/// macro body can name an item and the resolver's item leg is unreachable from one.
/// The zero is NOT "nothing to report" (0983's reading — B340's grey `.spawn` macro): the field
/// is three-valued and the usable compute reads each value differently, which [`SlotResolve`]
/// carries. Only the SPELL indirection is modelled: 1.12 has no `/use <item>` slash command, so
/// no 1.12 macro body can name an item and the resolver's item leg is unreachable from one.
fn resolve_through_macro(
kind: u8,
action: u32,
bound: &crate::ui_macro::MacroBoundSpells,
) -> Option<(u8, u32)> {
) -> SlotResolve {
use crate::ui_macro::BoundSpell;
match kind {
ACTION_KIND_MACRO => bound.0.get(&action).map(|&s| (ACTION_KIND_SPELL, s)),
other => Some((other, action)),
ACTION_KIND_MACRO => match bound.0.get(&action) {
Some(BoundSpell::Spell(s)) => SlotResolve::Action(ACTION_KIND_SPELL, *s),
Some(BoundSpell::None) => SlotResolve::BareMacro,
Some(BoundSpell::Unresolved) | None => SlotResolve::Dead,
},
other => SlotResolve::Action(other, action),
}
}
@@ -369,10 +392,23 @@ pub(super) fn feed_action_state(
for (&slot, button) in &actions.buttons {
let action = u32::from(slot) + 1;
let mut st = ActionState::default();
let Some((kind, id)) = resolve_through_macro(button.kind, button.action, bound) else {
// A macro that casts nothing reports nothing — the reference's `[rec+0x564] == 0`.
fresh.insert(action, st);
continue;
let (kind, id) = match resolve_through_macro(button.kind, button.action, bound) {
SlotResolve::Action(kind, id) => (kind, id),
SlotResolve::BareMacro => {
// The spell-less leg of `0x4e5050`: the macro exists, so the slot is usable —
// full colour on the bar — and there is no other state to compute (1636). The
// leg's one gate benilla does not model is `[0xb4b3e4]`, the player-control
// flag (wow-re `right-click-open.md` §3.1: 1 from boot, 0 only across a control
// loss — taxi/fear/charm); for that span the reference greys every spell-less
// macro and item.
st.usable = true;
fresh.insert(action, st);
continue;
}
SlotResolve::Dead => {
fresh.insert(action, st);
continue;
}
};
let button = &benilla_protocol::messages::ActionButton {
slot,
@@ -694,32 +730,116 @@ mod tests {
}
/// A MACRO slot resolves through its bound spell for EVERY dynamic read (decision 0983) —
/// the `0x4e5a50` law — while an unbound macro reports nothing at all.
/// the `0x4e5a50` law — and the three values of `[rec+0x564]` split three ways at the usable
/// compute (decision 1636): a live spell IS that spell; a macro that casts nothing is a bare,
/// usable button (B340's `.spawn` macro); an unresolved `/cast` — or a slot whose macro is
/// gone — is grey.
#[test]
fn a_macro_slot_resolves_through_its_bound_spell() {
use crate::ui_macro::BoundSpell;
use benilla_protocol::messages::ACTION_KIND_MACRO;
let mut bound = crate::ui_macro::MacroBoundSpells::default();
bound.0.insert(3, 133); // macro 3 casts Fireball
bound.0.insert(3, BoundSpell::Spell(133)); // macro 3 casts Fireball
bound.0.insert(4, BoundSpell::None); // macro 4 is `.spawn 16032`
bound.0.insert(5, BoundSpell::Unresolved); // macro 5 is `/cast Pyroblast`, unknown
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 3, &bound),
Some((ACTION_KIND_SPELL, 133)),
SlotResolve::Action(ACTION_KIND_SPELL, 133),
"from here down the macro IS the Fireball slot"
);
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 4, &bound),
None,
"a macro that casts nothing has no cooldown, no range, no usability"
SlotResolve::BareMacro,
"a macro that casts nothing is usable, with no cooldown, no range"
);
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 5, &bound),
SlotResolve::Dead,
"a /cast of an unknown spell is the reference's -1: grey"
);
assert_eq!(
resolve_through_macro(ACTION_KIND_MACRO, 6, &bound),
SlotResolve::Dead,
"a slot whose macro no longer exists: grey"
);
// Spell and item slots pass through untouched.
assert_eq!(
resolve_through_macro(ACTION_KIND_SPELL, 133, &bound),
Some((ACTION_KIND_SPELL, 133))
SlotResolve::Action(ACTION_KIND_SPELL, 133)
);
assert_eq!(
resolve_through_macro(ACTION_KIND_ITEM, 117, &bound),
Some((ACTION_KIND_ITEM, 117))
SlotResolve::Action(ACTION_KIND_ITEM, 117)
);
}
/// The feed end to end, at the symptom (B340): a MACRO slot whose macro casts nothing is
/// pushed **usable** — `IsUsableAction` answers true in the VM, the full-colour icon — while
/// a `/cast` of an unknown spell, and a slot whose macro is gone, are pushed grey. The
/// pre-1636 feed pushed `ActionState::default()` for all three, whose `usable` is false: every
/// GM `.spawn` macro on the bar was grey.
#[test]
fn the_feed_pushes_a_bare_macro_as_usable() {
use crate::ui_macro::{BoundSpell, MacroBoundSpells};
use benilla_protocol::messages::{ActionButton, ACTION_KIND_MACRO};
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut actions = PlayerActions::default();
for (slot, index) in [(0u8, 1u32), (1, 2), (2, 3)] {
actions.buttons.insert(
slot,
ActionButton {
slot,
action: index,
kind: ACTION_KIND_MACRO,
},
);
}
// Macro 1 is `.spawn 16032`, macro 2 is `/cast Pyroblast` with Pyroblast unknown, and
// macro 3 does not exist.
let mut bound = MacroBoundSpells::default();
bound.0.insert(1, BoundSpell::None);
bound.0.insert(2, BoundSpell::Unresolved);
app.insert_resource(actions)
.insert_resource(bound)
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_script::UiClock>()
.init_resource::<AutoRepeatActive>()
.init_resource::<crate::ui_cast::PendingCast>()
.init_resource::<crate::ui_cast::QueuedMeleeSpell>()
.init_resource::<crate::ui_cast::ActiveChannel>()
.init_resource::<crate::ui_action::SpellTargeting>()
.init_resource::<Selection>()
.init_resource::<GuidIndex>()
.init_resource::<crate::net::Reputations>()
.init_resource::<Items>()
.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
app.add_systems(Update, feed_action_state);
app.update();
let script = app.world().non_send_resource::<UiScript>();
let usable = |action: u32| {
script
.eval::<bool>(&format!(
"return (IsUsableAction({action})) and true or false"
))
.unwrap()
};
assert!(usable(1), "a macro that casts nothing is a usable button");
assert!(
!usable(2),
"a /cast of an unknown spell is grey (the reference's -1)"
);
assert!(!usable(3), "a slot whose macro no longer exists is grey");
assert!(
!script
.eval::<bool>("local _, oom = IsUsableAction(2) return oom and true or false")
.unwrap(),
"grey, not the out-of-power blue: notEnoughMana stays 0 on the spell-less leg"
);
}
+22 -10
View File
@@ -41,13 +41,20 @@ pub(crate) struct MacroFiles {
identity: crate::ui_script::VmMemo<Option<(String, String)>>,
}
/// Macro index → the spell it casts — benilla's `[rec+0x564]` (wow-re
/// `action-spell-icon-apis.md` §2), the field an action-bar MACRO slot's whole dynamic state reads
/// through. Stored, not derived at read time, for the reference's own reason: it is a **field on
/// the macro record**, recomputed when the macro (or the book) changes, so the three per-frame
/// action-bar systems pay a hash lookup instead of a body re-parse each.
/// Macro index → its bound spell — benilla's `[rec+0x564]` (wow-re `action-spell-icon-apis.md`
/// §2), the field an action-bar MACRO slot's whole dynamic state reads through, in the
/// reference's own three values ([`BoundSpell`]). Stored, not derived at read time, for the
/// reference's own reason: it is a **field on the macro record**, recomputed when the macro (or
/// the book) changes, so the three per-frame action-bar systems pay a hash lookup instead of a
/// body re-parse each.
///
/// **One entry per macro that exists**, whatever it binds — absence means the index names no
/// macro, which is the usable compute's `0x4e5030` verdict and reads differently from a macro
/// that merely casts nothing (decision 1636: the former is grey, the latter is not).
#[derive(Resource, Default)]
pub(crate) struct MacroBoundSpells(pub(crate) std::collections::HashMap<u32, u32>);
pub(crate) struct MacroBoundSpells(pub(crate) std::collections::HashMap<u32, BoundSpell>);
pub(crate) use run::BoundSpell;
pub(crate) struct UiMacroPlugin;
@@ -222,12 +229,17 @@ fn rebind_macro_spells(
let Some(m) = macros.get(index as usize) else {
continue;
};
if let Some(spell) = run::bound_spell(&table, &m.body, &book) {
fresh.insert(index, spell);
}
fresh.insert(index, run::bound_spell(&table, &m.body, &book));
}
if fresh != bound.0 {
debug!("ui_macro: {} macro(s) bound to a spell", fresh.len());
let spells = fresh
.values()
.filter(|b| matches!(b, BoundSpell::Spell(_)))
.count();
debug!(
"ui_macro: {} macro(s), {spells} bound to a spell",
fresh.len()
);
bound.0 = fresh;
}
}
+104 -32
View File
@@ -52,12 +52,17 @@
//! So [`bound_spell`] and the press going through one [`resolve_spell_by_name`] is the reference's
//! shape, not a convenience.
//!
//! One thing the reference distinguishes and benilla does not: a `/cast` line whose name did not
//! resolve stores **-1**, a `CastSpellByName(` line whose name did not resolve stores **0**, and the
//! incremental re-derive retries only negatives — so a failed `CastSpellByName` is never retried
//! while a failed `/cast` is. benilla recomputes the whole table off two change signals instead
//! (`rebind_macro_spells`), which retries both; strictly more forgiving, and the reason the
//! asymmetry has no observable to reproduce here.
//! **The field is three-valued, and the third value shows on the bar** (decision 1636): a `/cast`
//! line whose name did not resolve stores **-1**; a `CastSpellByName(` line whose name did not
//! resolve stores **0** — the same 0 a body with no cast line at all stores. The usable compute
//! `0x4e5050` reads the difference: a 0 takes its spell-less leg, which answers usable=1 for any
//! macro that exists (`0x4e5030`), while a -1 falls into the spell path and fails its `jl` at
//! `0x4e518b` — the 0.4 grey. So `/cast Pyroblast` before you know Pyroblast is a grey button and
//! `/script CastSpellByName("Pyroblast")` is a full-colour one; [`BoundSpell`] carries all three
//! so the bar can tell them apart. The reference's incremental re-derive retries only negatives
//! (a failed `CastSpellByName` is never retried, a failed `/cast` is); benilla recomputes the
//! whole table off two change signals instead (`rebind_macro_spells`), which retries both —
//! strictly more forgiving, and no observable differs.
//!
//! **`[rec+0x568]` is NOT what 0983 said it was.** It is the SPELLBOOK the bound spell resolved
//! from — 0 = the player's list, 1 = the PET's (Lua's `BOOKTYPE_SPELL`/`BOOKTYPE_PET`) — not "the
@@ -73,6 +78,33 @@ use crate::ui_chat::commands::{Command, SlashCommands, SlashIndex};
/// case-SENSITIVELY anywhere in the line (`0x4efed5` → `0x64b4f0` → a `rep cmpsb` `strncmp`).
const CAST_BY_NAME_CALL: &str = "CastSpellByName(";
/// Which of `0x4efe00`'s two arms matched the cast line — load-bearing because the two store
/// different values for a name that does not resolve (wow-re `macro-execution-law.md` §7: arm A
/// writes `-1` at `0x4eff48`, arm B stores the resolver's `0` verbatim at `0x4eff81`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CastArm {
/// A `/cast`-alias line (`SLASH_CAST%d`, read as Lua globals).
Slash,
/// A `CastSpellByName("…")` call anywhere in the line.
ByName,
}
/// The reference's `[rec+0x564]` — a macro record's cached bound spell — as the three values it
/// actually takes (wow-re `macro-execution-law.md` §7, VERIFIED; the usable consequence is
/// decision 1636). The action bar reads this through `ui_action::state`'s slot resolve.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum BoundSpell {
/// `0` — no line matched either arm, or a `CastSpellByName(` name that did not resolve. The
/// bar treats the slot as a bare macro: usable, no spell state.
#[default]
None,
/// `-1` — a `/cast` line whose name did not resolve. The bar greys the slot (the spell path
/// of `0x4e5050` refuses a negative id at `0x4e518b`).
Unresolved,
/// A live spell id: from here down the slot IS that spell.
Spell(u32),
}
/// A macro body's runnable lines. The reference's tokenizer (`0x64ae50`) takes `"\r\n"` as a
/// **delimiter SET** — either character splits — and skips empty tokens, which is why an interior
/// blank line never even produces one (wow-re `macro-execution-law.md` §3/§6). So do we; a
@@ -98,7 +130,7 @@ pub(crate) fn macro_lines(body: &str) -> impl Iterator<Item = &str> {
/// (never a hardcoded `"/cast"` — decision 0881's law, and the reference's own: it reads
/// `SLASH_CAST1`, `SLASH_CAST2`, … out of the Lua globals), argument taken whole; or
/// - a line containing `CastSpellByName(` with a quoted first argument.
pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<String> {
pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<(CastArm, String)> {
for line in macro_lines(body) {
// Arm A. The separator after the alias must be a literal `' '` — the reference compares
// `[ebp+ebx-0x108]` against `0x20` exactly (`0x4efe96`), so a TAB does not match and a
@@ -109,7 +141,7 @@ pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<String> {
if table.lookup(cmd) == Some(Command::Slash(SlashIndex::Cast)) {
let args = args.trim();
if !args.is_empty() {
return Some(args.to_string());
return Some((CastArm::Slash, args.to_string()));
}
// The ref falls through to arm B on this same line, then to the next line.
continue;
@@ -117,7 +149,7 @@ pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<String> {
}
// Arm B, second and on the same line (`0x4efed5`).
if let Some(name) = quoted_call_argument(line) {
return Some(name);
return Some((CastArm::ByName, name));
}
}
None
@@ -135,14 +167,20 @@ fn quoted_call_argument(line: &str) -> Option<String> {
(!name.is_empty()).then(|| name.to_string())
}
/// The macro's bound spell id — [`cast_name`] resolved against the player's book by the same law
/// The macro's bound spell — [`cast_name`] resolved against the player's book by the same law
/// `CastSpellByName` itself uses ([`resolve_spell_by_name`]), so the icon's cooldown swirl and the
/// press always agree about which rank is meant. `None` for a macro that casts nothing, or names a
/// spell this character does not know: the slot then reports no cooldown and no range, which is
/// what the reference's `0x4e5a50` produces for a `[rec+0x564]` of 0.
pub(crate) fn bound_spell(table: &SlashCommands, body: &str, book: &SpellBookState) -> Option<u32> {
let name = cast_name(table, body)?;
resolve_spell_by_name(book, &name).map(|s| s.spell_id)
/// press always agree about which rank is meant. The miss is arm-dependent, exactly as the
/// reference stores it (the module doc): a `/cast` of a name this character does not know is
/// [`BoundSpell::Unresolved`], a `CastSpellByName(` of one is [`BoundSpell::None`].
pub(crate) fn bound_spell(table: &SlashCommands, body: &str, book: &SpellBookState) -> BoundSpell {
let Some((arm, name)) = cast_name(table, body) else {
return BoundSpell::None;
};
match (resolve_spell_by_name(book, &name), arm) {
(Some(s), _) => BoundSpell::Spell(s.spell_id),
(None, CastArm::Slash) => BoundSpell::Unresolved,
(None, CastArm::ByName) => BoundSpell::None,
}
}
#[cfg(test)]
@@ -179,23 +217,26 @@ mod tests {
let t = table();
assert_eq!(
cast_name(&t, "/target Bob\n/cast Fireball\n/say pew"),
Some("Fireball".into())
Some((CastArm::Slash, "Fireball".into()))
);
assert_eq!(
cast_name(&t, "/spell Frostbolt"),
Some((CastArm::Slash, "Frostbolt".into()))
);
assert_eq!(cast_name(&t, "/spell Frostbolt"), Some("Frostbolt".into()));
// The whole argument, subtext included — `resolve_spell_by_name` owns that grammar.
assert_eq!(
cast_name(&t, "/cast Fireball(Rank 1)"),
Some("Fireball(Rank 1)".into())
Some((CastArm::Slash, "Fireball(Rank 1)".into()))
);
// First match wins.
assert_eq!(
cast_name(&t, "/cast Fireball\n/cast Frostbolt"),
Some("Fireball".into())
Some((CastArm::Slash, "Fireball".into()))
);
// A bare `/cast` binds nothing and does not stop the walk.
assert_eq!(
cast_name(&t, "/cast\n/cast Frostbolt"),
Some("Frostbolt".into())
Some((CastArm::Slash, "Frostbolt".into()))
);
assert_eq!(cast_name(&t, "/say hello\n/target Bob"), None);
}
@@ -207,12 +248,13 @@ mod tests {
#[test]
fn the_alias_separator_is_a_literal_space_and_the_alias_folds_case() {
let t = table();
assert_eq!(cast_name(&t, "/CAST Fireball"), Some("Fireball".into()));
assert_eq!(cast_name(&t, "/Cast Fireball"), Some("Fireball".into()));
let fireball = Some((CastArm::Slash, "Fireball".into()));
assert_eq!(cast_name(&t, "/CAST Fireball"), fireball);
assert_eq!(cast_name(&t, "/Cast Fireball"), fireball);
// A tab does not separate — the ref reads it as "not a /cast line" and walks on.
assert_eq!(
cast_name(&t, "/cast\tFireball\n/cast Frostbolt"),
Some("Frostbolt".into())
Some((CastArm::Slash, "Frostbolt".into()))
);
// Arm B is case-SENSITIVE (a `rep cmpsb` strncmp), so the lowercased spelling misses.
assert_eq!(
@@ -227,12 +269,12 @@ mod tests {
let t = table();
assert_eq!(
cast_name(&t, r#"/script CastSpellByName("Shadow Bolt")"#),
Some("Shadow Bolt".into())
Some((CastArm::ByName, "Shadow Bolt".into()))
);
// Spacing and a second argument don't matter; the first quoted argument is the name.
assert_eq!(
cast_name(&t, r#"/script CastSpellByName( "Healing Touch", 1 )"#),
Some("Healing Touch".into())
Some((CastArm::ByName, "Healing Touch".into()))
);
// A computed argument has no name to bind — neither here nor in the reference.
assert_eq!(cast_name(&t, "/script CastSpellByName(spell)"), None);
@@ -264,12 +306,42 @@ mod tests {
};
let t = table();
// No subtext -> the highest known rank.
assert_eq!(bound_spell(&t, "/cast Fireball", &book), Some(145));
assert_eq!(
bound_spell(&t, "/cast Fireball", &book),
BoundSpell::Spell(145)
);
// A pinned subtext -> that rank, both spacings.
assert_eq!(bound_spell(&t, "/cast Fireball(Rank 1)", &book), Some(133));
assert_eq!(bound_spell(&t, "/cast Fireball (Rank 1)", &book), Some(133));
// Unknown spell / no cast line -> nothing bound (the slot reports no cooldown).
assert_eq!(bound_spell(&t, "/cast Pyroblast", &book), None);
assert_eq!(bound_spell(&t, "/say hi", &book), None);
assert_eq!(
bound_spell(&t, "/cast Fireball(Rank 1)", &book),
BoundSpell::Spell(133)
);
assert_eq!(
bound_spell(&t, "/cast Fireball (Rank 1)", &book),
BoundSpell::Spell(133)
);
}
/// The miss is arm-dependent, as the reference stores it (`macro-execution-law.md` §7 +
/// decision 1636): a `/cast` of a spell this character does not know is the `-1` the bar
/// greys; a body with no cast line, or a `CastSpellByName(` of an unknown name, is the `0`
/// the bar draws full-colour with no spell state.
#[test]
fn an_unresolved_cast_is_arm_dependent() {
let book = SpellBookState::default();
let t = table();
assert_eq!(
bound_spell(&t, "/cast Pyroblast", &book),
BoundSpell::Unresolved
);
assert_eq!(
bound_spell(&t, "/spell Pyroblast\n/say pew", &book),
BoundSpell::Unresolved
);
assert_eq!(bound_spell(&t, "/say hi", &book), BoundSpell::None);
assert_eq!(bound_spell(&t, ".spawn 16032", &book), BoundSpell::None);
assert_eq!(
bound_spell(&t, r#"/script CastSpellByName("Pyroblast")"#, &book),
BoundSpell::None
);
}
}
+2 -2
View File
@@ -972,7 +972,7 @@ pub(crate) struct UiMeshCost {
/// How many pooled batch meshes were REWRITTEN this rebuild, rather than left alone or moved
/// by a translation-only nudge (1361's skip gate). This is the number that reaches Bevy: each
/// rewrite re-extracts in `RenderExtractApp`, which is where a hover's real cost turned out to
/// live (decision 1632). `rewrites == runs` every frame means the gate is being defeated for
/// live (decision 1634). `rewrites == runs` every frame means the gate is being defeated for
/// every batch at once, which is what a z coupled to the run count did.
pub(crate) rewrites: usize,
}
@@ -1201,7 +1201,7 @@ fn rebuild_ui_mesh(
// Spread runs across a z window comfortably inside the camera's default near/far (±1000) regardless
// of run count, so this never depends on how many runs a given frame happens to produce.
//
// NB (decision 1632): this z DOES move when the run count moves, and `translation_from` bails on
// NB (decision 1634): this z DOES move when the run count moves, and `translation_from` bails on
// `z_bits` first — so it looks like a hover (which adds a run: the ButtonHilight is additive with
// its own texture and can never merge) would defeat 1361's skip gate for every batch at once.
// It was tried, with a constant denominator, and MEASURED: no change to the hover cost, because
@@ -665,6 +665,76 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The macro-name line (ref `ActionButton_Update:236-238`, "Update Macro Text") through the REAL
/// shipped XML: a MACRO slot's button reads its macro's name under the icon, a SPELL slot's reads
/// nothing, and a macro slot that empties loses the name through the same unconditional write.
/// B340 (decision 1636): the template declared `$parentName` and nothing ever set it, so every
/// macro on the bar was nameless.
#[test]
fn macro_name_line_follows_get_action_text_through_the_xml() {
use benilla_ui::script::{MacroState, MacroView};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_macros(MacroState {
account: vec![MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
kind: 0x40, // MACRO
action: 1, // macro index 1
count: 0,
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00, // SPELL
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Read the fontstrings by name (the count test's reason: painted text is ambiguous).
let name_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!("return ActionButton{n}Name:GetText() or \"\""))
.unwrap()
};
assert_eq!(
name_of(&s, 1),
"spawn",
"a MACRO slot wears its macro's name"
);
assert_eq!(name_of(&s, 2), "", "a SPELL slot has no name line");
assert!(
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "spawn")),
"the name reaches the screen"
);
// The slot empties: the ref's write is unconditional, so a nil clears the line.
s.set_action(1, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
s.resolve();
assert_eq!(name_of(&s, 1), "", "an emptied slot loses the name");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn shipped_bag_frame_drives_end_to_end() {
use benilla_ui::script::{ContainerSlot, ContainerState};
+637 -85
View File
@@ -45,6 +45,13 @@ fn ui_diff_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_UI_DIFF").is_some())
}
/// `WOW_UI_SPLICE_VERIFY=1` — the splice's own adversary (decision 1638). Read once, off by
/// default, and *expensive by design*: it makes every spliced frame also pay the full conversion.
fn splice_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_UI_SPLICE_VERIFY").is_some())
}
/// `WOW_UI_PICK=<x>,<y>[,<r>]` — **what drew this pixel?** The UI's answer to the world hover
/// inspector. Every converted quad whose screen rect covers that point (or, with `r`, comes
/// within `r` px of it — how a hairline thinner than the probe is caught) names itself once: the
@@ -105,6 +112,21 @@ fn report_ui_pick(eq: &benilla_ui::script::ExtractedQuad, rect: Rect, at: Vec2,
);
}
/// A content arm's name, for a one-line log ([`report_gate_miss`]'s splice twin). Short and
/// stable: these strings are histogrammed in the shell, not read as prose.
fn content_kind(c: &QuadContent) -> &'static str {
match c {
QuadContent::Frame => "frame",
QuadContent::Minimap { .. } => "minimap",
QuadContent::Cooldown { .. } => "cooldown",
QuadContent::Texture { .. } => "texture",
QuadContent::ColorWheel => "colorwheel",
QuadContent::ColorValue { .. } => "colorvalue",
QuadContent::Backdrop { .. } => "backdrop",
QuadContent::Text { .. } => "text",
}
}
/// The half-texel-inset UV window a **cropped** quad may sample — [`UiQuad::uv_clamp`]'s producer
/// (decision 1608), `None` when neither axis needs one.
///
@@ -226,6 +248,79 @@ pub(super) struct GateInputs {
/// conversion, kept current by the splice — this is what lets a one-entry change (the resting
/// blink) re-convert one entry instead of the whole interface.
spans: Vec<u32>,
/// The stitch's ping-pong buffer: last frame's quad allocation, emptied. A stitch drains the
/// live list into a fresh one and parks the drained allocation here, so a steady stream of
/// changes (a hovered tooltip re-filling every frame) reuses two buffers forever instead of
/// allocating ~1,400 quads a frame.
held: Vec<UiQuad>,
}
/// How this frame's entry list lines up with last frame's — what the splice re-converts, and
/// where everything it keeps came from.
struct Alignment {
/// For each entry of the NEW list: the OLD index whose already-converted quads it reuses, or
/// `None` — it must be converted. Kept indices are strictly increasing.
source: Vec<Option<usize>>,
/// The OLD entries nothing reuses. Their quads leave the list, so their conversion's side
/// effects (a parked minimap slot, a shine site, a link span) would have to be *undone* — the
/// splice cannot, so it only proceeds when every one of them was quads-only.
dropped: Vec<usize>,
}
/// The alignment: a merge over `z`, which both lists are sorted by ([`benilla_ui::script::UiScript::extract`]).
///
/// **Why `z` and not the index.** `z` is a packed `(strata, level, frame-insertion, layer, …)`
/// key ([`benilla_ui::order::ZKey`]) — an entry's *identity*, unchanged when a neighbour is
/// inserted or removed. A positional compare cannot see that: hovering one item slot inserts a
/// single highlight entry at index 359 of 520 and shifts the 160 entries behind it, every one of
/// which then compares unequal by index and equal by `z`. Before this, that frame took the full
/// conversion — 86 % of all hover frames did (decision 1638).
///
/// **The merge FINDS the alignment; it is never trusted.** Quads are reused only for a pair that
/// compares fully equal, and [`convert_entry`] is a pure function of the entry plus the raster
/// environment the splice's guards pin — so an equal pair converts to equal quads whatever their
/// indices. A merge that mis-pairs therefore costs conversions and never correctness, which is
/// what makes it safe to key on a `z` we do not prove unique or even prove sorted. (It is not
/// quite sorted: a ScrollingMessageFrame's own content line is emitted at its frame's slot and
/// sorts above the frame's BACKGROUND regions, so the list dips locally around a chat window.
/// Identical dips on both sides match anyway; a differing one re-converts a handful of entries.)
fn align_entries(
was: &[benilla_ui::script::ExtractedQuad],
now: &[benilla_ui::script::ExtractedQuad],
) -> Alignment {
let mut source: Vec<Option<usize>> = Vec::with_capacity(now.len());
let mut kept = vec![false; was.len()];
let (mut i, mut j) = (0usize, 0usize);
while j < now.len() {
let Some(w) = was.get(i) else {
// Last frame's list is spent: everything left is new.
source.push(None);
j += 1;
continue;
};
match w.z.cmp(&now[j].z) {
std::cmp::Ordering::Equal => {
let same = *w == now[j];
source.push(same.then_some(i));
kept[i] = same;
i += 1;
j += 1;
}
// An entry that is gone: last frame drew something this frame does not.
std::cmp::Ordering::Less => i += 1,
// An entry that is new.
std::cmp::Ordering::Greater => {
source.push(None);
j += 1;
}
}
}
let dropped = kept
.iter()
.enumerate()
.filter_map(|(i, k)| (!k).then_some(i))
.collect();
Alignment { source, dropped }
}
/// Entry `i`'s quad range under `spans` (the [`GateInputs::spans`] encoding).
@@ -234,6 +329,152 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
(a, spans[i] as usize)
}
/// Whether an entry's conversion writes **quads and nothing else** — the splice's admission test.
/// The full path plumbs four side channels (the engine's link spans, the parked minimap slot, the
/// shine sites, the booth panes); the splice keeps last frame's values for all of them, which is
/// only sound for entries that never write one. The autocast-shine token (decision 1383) is a
/// side-channel kind wearing a Texture's clothes, so it is excluded by path.
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
| QuadContent::ColorValue { .. } => true,
// A FontString's text writes nothing but quads, and that is every label, every unit
// frame's name, every tooltip line — the traffic this whole path exists for (decision
// 1638). A FRAME-targeted Text quad is a message-frame ring line, whose hyperlink spans
// are a REPLACE-THE-WHOLE-SET channel (`set_link_spans`): a line that stops carrying a
// link would leave a stale clickable rect behind, and the splice's post-conversion
// tripwire cannot see that, because the scratch it inspects is empty in exactly that
// case. So the target, not the tripwire, is the guard.
QuadContent::Text { .. } => matches!(eq.target, benilla_ui::order::ZTarget::Region(_)),
QuadContent::Texture {
portrait_unit: None,
path,
..
} => !path
.as_deref()
.is_some_and(|p| crate::autocast_shine::token_model_scale(p).is_some()),
_ => false,
}
}
/// The splice's adversary ([`splice_verify_enabled`]): re-run the FULL conversion over the same
/// entry list and prove the spliced list is what it would have produced — quads AND span table.
///
/// This is the check the splice's whole argument rests on, made by machine instead of by
/// reasoning. It is what turns "an equal entry converts to equal quads" from a claim about
/// [`convert_entry`] into a measurement over a real interface: a live client with ~520 entries, a
/// tooltip re-filling every frame, and a hover highlight inserting and removing itself under it
/// (decision 1638). A mismatch names the quad, the entry that owns it, and that entry's owner.
///
/// The side channels are collected into throwaway buffers and dropped — the point is the quads.
/// `booths.panes` is the one exception, because [`convert_entry`] writes it through the shared
/// bridge; re-adding the same tokens is idempotent, so a verify run's panes end up where the full
/// path would have put them anyway.
#[allow(clippy::too_many_arguments)]
fn verify_splice(
prev: &GateInputs,
quads: &UiQuads,
s: f32,
w: f32,
h: f32,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
booths: &mut crate::portrait::BoothBridge,
text_ui: Option<&benilla_ui::script::EditBoxTextUi>,
caret_pinned: bool,
script: &UiScript,
) {
let mut check: Vec<UiQuad> = Vec::with_capacity(quads.quads.len());
let mut spans: Vec<u32> = Vec::with_capacity(prev.extracted.len());
let (mut links, mut slot, mut shine) = (Vec::new(), None, Vec::new());
for eq in prev.extracted.iter().cloned() {
convert_entry(
eq,
s,
w,
h,
assets,
images,
font_atlas,
booths,
text_ui,
caret_pinned,
&mut check,
&mut links,
&mut slot,
&mut shine,
);
spans.push(check.len() as u32);
}
if spans != prev.spans {
let at = spans
.iter()
.zip(&prev.spans)
.position(|(a, b)| a != b)
.unwrap_or(spans.len().min(prev.spans.len()));
eprintln!(
"[ui-splice] VERIFY FAIL: span table diverges at entry {at} of {} \
(spliced end {:?}, full end {:?})",
prev.extracted.len(),
prev.spans.get(at),
spans.get(at),
);
return;
}
let Some(i) = check
.iter()
.zip(&quads.quads)
.position(|(a, b)| a != b)
.or_else(|| {
(check.len() != quads.quads.len()).then_some(check.len().min(quads.quads.len()))
})
else {
return;
};
// Which entry owns quad `i` — the span table is a prefix-end list, so the first end past it.
let owner = spans.partition_point(|&e| e as usize <= i);
let name = prev
.extracted
.get(owner)
.and_then(|eq| script.target_owner_name(eq.target));
eprintln!(
"[ui-splice] VERIFY FAIL: quad {i} of {}/{} differs — entry {owner} ({}, {})\n spliced {}\n full {}",
quads.quads.len(),
check.len(),
name.as_deref().unwrap_or("?"),
prev.extracted
.get(owner)
.map_or("<none>", |eq| content_kind(&eq.content)),
quad_summary(quads.quads.get(i)),
quad_summary(check.get(i)),
);
}
/// One quad in a log line — enough to see WHICH way two conversions disagree (geometry, paint,
/// crop, or which texture), never the whole struct.
fn quad_summary(q: Option<&UiQuad>) -> String {
let Some(q) = q else {
return "<past the end>".into();
};
format!(
"z={} rect=({:.2},{:.2})-({:.2},{:.2}) color={:?} uv={:?} tex={:?}",
q.z_key,
q.rect.min.x,
q.rect.min.y,
q.rect.max.x,
q.rect.max.y,
q.color,
q.uv.corners,
q.texture.as_ref().map(|t| t.id()),
)
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
/// drain to the log (throttled by being drained — each fires once).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
@@ -603,6 +844,7 @@ pub(super) fn drive_script(
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: true,
spliced: 0,
dropped: 0,
};
}
if printing {
@@ -643,76 +885,97 @@ pub(super) fn drive_script(
// which the unchanged entries prove are this frame's too: the settled path's own argument,
// applied per entry.
'splice: {
if capture.is_some()
|| ui_diff_enabled()
|| prev.dims != Some(dims)
|| prev.generation != generation
|| text_ui != prev.text_ui
|| booths.images.0 != prev.portraits
|| prev.extracted.len() != extracted.len()
|| prev.spans.len() != extracted.len()
// spans describe `quads.quads` — if any future writer replaces the base lane out
// from under this pass, degrade to the full conversion instead of mis-stitching.
// `WOW_UI_GATE=1` names why the SPLICE declined too — one line per frame, the same dial
// and the same posture as `[ui-gate]` above, because "the gate missed, and then the
// splice missed too" is one question asked twice. Histogram it in the shell.
macro_rules! no_splice {
($($arg:tt)*) => {{
if gate_log_enabled() {
eprintln!("[ui-splice] miss: {}", format_args!($($arg)*));
}
break 'splice;
}};
}
if capture.is_some() {
no_splice!("capture mode");
}
if ui_diff_enabled() {
no_splice!("WOW_UI_DIFF pins the full path");
}
if prev.dims != Some(dims) {
no_splice!("window dims moved");
}
if prev.generation != generation {
no_splice!("font atlas generation moved");
}
// The focused edit box carries the caret BLINK — wall-clock state that appears in no
// entry, and the one time-dependent input the Text arm reads. It is what makes text
// splice-safe at all (decision 1638): everything else the conversion reads is either in
// the entry or pinned by a guard above.
if text_ui != prev.text_ui {
no_splice!("focused editbox text-ui moved");
}
if booths.images.0 != prev.portraits {
no_splice!("portrait sources moved");
}
// spans describe last conversion's `quads.quads` — if any future writer replaces the
// base lane out from under this pass, degrade to the full conversion instead of
// mis-stitching.
if prev.spans.len() != prev.extracted.len()
|| prev.spans.last().copied().unwrap_or(0) as usize != quads.quads.len()
{
break 'splice;
no_splice!("span table stale");
}
// The changed entry set, bounded: a real UI transition (a window opening) moves many
// entries, and the full conversion is the right tool there anyway.
const SPLICE_MAX: usize = 8;
let mut changed: Vec<usize> = Vec::with_capacity(SPLICE_MAX);
for (i, (now, was)) in extracted.iter().zip(&prev.extracted).enumerate() {
if now != was {
if changed.len() == SPLICE_MAX {
break 'splice;
}
changed.push(i);
}
}
// The all-equal case belongs to the settled gate above; reaching here empty means a
// comparison razor slipped — take the full path rather than risk skipping a change.
if changed.is_empty() {
break 'splice;
}
// Only entries whose conversion writes quads alone are spliceable — old AND new: an
// entry morphing into a side-channel kind must reach the full path's plumbing. The
// autocast-shine token (decision 1383) is a side-channel kind wearing a Texture's
// clothes, so it is excluded by path.
let simple = |eq: &benilla_ui::script::ExtractedQuad| match &eq.content {
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
| QuadContent::ColorValue { .. } => true,
QuadContent::Texture {
portrait_unit: None,
path,
..
} => !path
.as_deref()
.is_some_and(|p| crate::autocast_shine::token_model_scale(p).is_some()),
_ => false,
};
if !changed
let align = align_entries(&prev.extracted, &extracted);
// The entries this frame has to pay for, bounded: a real UI transition (a window
// opening) moves many, and the full conversion is the right tool there anyway.
const SPLICE_MAX: usize = 64;
let changed: Vec<usize> = align
.source
.iter()
.all(|&i| simple(&extracted[i]) && simple(&prev.extracted[i]))
{
break 'splice;
.enumerate()
.filter_map(|(j, s)| s.is_none().then_some(j))
.collect();
if changed.len() + align.dropped.len() > SPLICE_MAX {
no_splice!(
"{} entries changed and {} dropped, over the {SPLICE_MAX} bound",
changed.len(),
align.dropped.len()
);
}
// Re-convert just the changed entries. The scratch side channels are a tripwire:
// `simple` makes them unreachable today, and if an arm ever grows a new side effect
// the full path is the only safe answer.
// The all-equal case belongs to the settled gate above; reaching here with nothing to do
// means a comparison razor slipped — take the full path rather than risk skipping a
// change. (A frame that only *drops* entries has real work and is not this case.)
if changed.is_empty() && align.dropped.is_empty() {
no_splice!("no entry differs (a comparison razor slipped)");
}
// Checked on both ends: every entry being converted, and every entry LEAVING the list —
// a departing minimap slot or shine site would have to be un-parked, which the splice has
// no way to do.
if let Some(eq) = changed
.iter()
.map(|&j| &extracted[j])
.chain(align.dropped.iter().map(|&i| &prev.extracted[i]))
.find(|eq| !splice_simple(eq))
{
no_splice!(
"a {} entry ({}) is not spliceable",
content_kind(&eq.content),
script.target_owner_name(eq.target).unwrap_or_default()
);
}
// Re-convert just those entries. The scratch side channels are a tripwire: `simple`
// makes them unreachable today, and if an arm ever grows a new side effect the full path
// is the only safe answer.
let mut scratch: Vec<UiQuad> = Vec::new();
let mut scratch_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(changed.len());
let mut scratch_links = Vec::new();
let mut scratch_slot = None;
let mut scratch_shine = Vec::new();
for &i in &changed {
for &j in &changed {
let at = scratch.len();
convert_entry(
extracted[i].clone(),
extracted[j].clone(),
s,
w,
h,
@@ -721,7 +984,7 @@ pub(super) fn drive_script(
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
false,
capture.is_some(),
&mut scratch,
&mut scratch_links,
&mut scratch_slot,
@@ -730,47 +993,92 @@ pub(super) fn drive_script(
scratch_ranges.push(at..scratch.len());
}
if !scratch_links.is_empty() || scratch_slot.is_some() || !scratch_shine.is_empty() {
break 'splice;
no_splice!("a re-converted entry wrote a side channel");
}
let counts_stable = changed.iter().zip(&scratch_ranges).all(|(&i, r)| {
let (a, b) = span_bounds(&prev.spans, i);
b - a == r.len()
});
// The in-place case: nothing was inserted or removed, so every kept entry is still at
// its own index and every changed one still owns the same stretch of the list. This is
// the resting blink's shape and it writes bytes without moving any.
let identity = prev.extracted.len() == extracted.len()
&& align
.source
.iter()
.enumerate()
.all(|(j, s)| s.is_none_or(|i| i == j));
let counts_stable = identity
&& changed.iter().zip(&scratch_ranges).all(|(&j, r)| {
let (a, b) = span_bounds(&prev.spans, j);
b - a == r.len()
});
let mut dirtied = false;
if counts_stable {
// The blink's shape — same quad count, new bytes: write the spans in place.
for (&i, r) in changed.iter().zip(&scratch_ranges) {
let (a, b) = span_bounds(&prev.spans, i);
for (&j, r) in changed.iter().zip(&scratch_ranges) {
let (a, b) = span_bounds(&prev.spans, j);
if quads.quads[a..b] != scratch[r.clone()] {
quads.quads[a..b].clone_from_slice(&scratch[r.clone()]);
dirtied = true;
}
}
} else {
// An entry's quad count moved: stitch a fresh list from the kept spans plus the
// re-converted ones, and re-derive the span table.
let mut new_out: Vec<UiQuad> = Vec::with_capacity(quads.quads.len() + scratch.len());
let mut new_spans: Vec<u32> = Vec::with_capacity(prev.spans.len());
let mut ci = 0;
for i in 0..extracted.len() {
if ci < changed.len() && changed[ci] == i {
new_out.extend_from_slice(&scratch[scratch_ranges[ci].clone()]);
ci += 1;
} else {
let (a, b) = span_bounds(&prev.spans, i);
new_out.extend_from_slice(&quads.quads[a..b]);
// Stitch a fresh list: the kept runs MOVED out of last frame's (a `UiQuad` carries an
// `Arc` texture handle, and copying ~1,400 of them a frame to rebuild a list is pure
// refcount traffic for no pixel), the re-converted entries from the scratch, and a
// re-derived span table. `prev.held` is the ping-pong buffer: the drain leaves last
// frame's allocation empty and we keep it for the next stitch, so a steady stream of
// tooltip changes allocates nothing.
let mut old = std::mem::take(&mut prev.held);
std::mem::swap(&mut old, &mut quads.quads);
quads.quads.clear();
quads.quads.reserve(old.len() + scratch.len());
let mut new_spans: Vec<u32> = Vec::with_capacity(extracted.len());
{
let mut src = old.drain(..);
// How many of last frame's quads the drain has already yielded — kept runs are
// strictly increasing (the merge only ever advances), so one forward pass does it.
let mut cursor = 0usize;
let mut ci = 0usize;
for s in &align.source {
match s {
Some(i) => {
let (a, b) = span_bounds(&prev.spans, *i);
if a > cursor {
src.by_ref().nth(a - cursor - 1);
}
quads.quads.extend(src.by_ref().take(b - a));
cursor = b;
}
None => {
quads
.quads
.extend_from_slice(&scratch[scratch_ranges[ci].clone()]);
ci += 1;
}
}
new_spans.push(quads.quads.len() as u32);
}
new_spans.push(new_out.len() as u32);
}
prev.held = old;
prev.spans = new_spans;
quads.quads = new_out;
dirtied = true;
}
if dirtied {
quads.dirty = true;
}
for &i in &changed {
prev.extracted[i] = extracted[i].clone();
prev.extracted = extracted;
if splice_verify_enabled() {
verify_splice(
prev,
&quads,
s,
w,
h,
&mut assets,
&mut images,
&mut font_atlas,
&mut booths,
text_ui.as_ref(),
capture.is_some(),
&script,
);
}
drop(extract_span);
let us_spl = lap();
@@ -790,6 +1098,7 @@ pub(super) fn drive_script(
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: false,
spliced: changed.len(),
dropped: align.dropped.len(),
};
}
if printing {
@@ -797,10 +1106,11 @@ pub(super) fn drive_script(
eprintln!(
"[ui-cost] tick={us_tick} resolve={us_resolve} measure={us_measure} \
exm={us_exm} exa={us_spl} diff=0 eq={n_extracted} quads={} \
solves={solves} changed={} skip=0 spliced={}",
solves={solves} changed={} skip=0 spliced={} dropped={}",
quads.quads.len(),
u8::from(dirtied),
changed.len()
changed.len(),
align.dropped.len()
);
}
return;
@@ -919,6 +1229,7 @@ pub(super) fn drive_script(
derives: script.layout_derivations() - derives_before.unwrap_or(0),
skipped: false,
spliced: 0,
dropped: 0,
};
if printing {
eprintln!(
@@ -1640,6 +1951,148 @@ mod clip_plumb_tests {
/// The extract gate (decision 0740): a settled frame skips the whole conversion loop, and — the
/// dangerous direction — any extract-visible change must reopen it, INCLUDING paint-only writes
/// that never dirty the layout gate. Uses the same minimal headless app as `clip_plumb_tests`.
/// The alignment merge ([`align_entries`]) — the half of the splice that sees through a shift.
///
/// The cases are built from a REAL extract list, because `FrameHandle` keeps its fields private
/// on purpose: an entry's `z` is a packed [`benilla_ui::order::ZKey`] and hand-forging one would
/// test a fiction rather than the traversal's own keys.
#[cfg(test)]
mod align_tests {
use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
use super::align_entries;
/// Four regions across two frames — a z-sorted list with genuine keys to permute.
fn entries() -> Vec<ExtractedQuad> {
let mut script = UiScript::new().unwrap();
script
.run(
r#"
for i = 1, 2 do
local f = CreateFrame("Frame", "F" .. i)
f:SetPoint("TOPLEFT", 0, 0)
f:SetSize(50, 50)
for j = 1, 2 do
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(i / 4, j / 4, 0)
t:SetAllPoints()
end
end
"#,
)
.unwrap();
script.resolve();
let list = script.extract();
assert!(
list.len() >= 6,
"two frames and four regions: {}",
list.len()
);
list
}
/// A repaint of one entry: it converts, its old self is dropped, and every other entry keeps
/// its own quads — the shape the splice has always handled, restated on the new machinery.
#[test]
fn a_changed_entry_is_the_only_one_that_converts() {
let was = entries();
let mut now = was.clone();
let at = now
.iter()
.position(|e| matches!(e.content, QuadContent::Texture { .. }))
.unwrap();
let QuadContent::Texture { color, .. } = &mut now[at].content else {
unreachable!()
};
*color = Some([0.0, 0.0, 1.0, 1.0]);
let a = align_entries(&was, &now);
assert_eq!(a.dropped, vec![at], "the repainted entry's old quads go");
for (j, s) in a.source.iter().enumerate() {
assert_eq!(
*s,
(j != at).then_some(j),
"entry {j} reuses its own quads unless it is the one that changed"
);
}
}
/// **The case a positional compare cannot see** (decision 1638): one entry appears in the
/// middle and every entry behind it shifts by one. All of them must keep their quads —
/// before this, index `j` was compared against a stranger and the whole list re-converted.
#[test]
fn an_insertion_shifts_the_tail_and_costs_one_conversion() {
let was = entries();
// The insertion needs a `z` strictly between its neighbours' — the merge reads a sorted
// list — so seat it at the first pair with room. Sibling regions differ by 1 in the key's
// declaration-order field; a frame boundary leaves a wide gap.
let at = (1..was.len())
.find(|&i| was[i].z - was[i - 1].z > 1)
.expect("some adjacent pair has room between its keys");
let mut now = was.clone();
let mut fresh = was[at].clone();
fresh.z = was[at - 1].z + 1;
now.insert(at, fresh);
let a = align_entries(&was, &now);
assert!(a.dropped.is_empty(), "nothing left the list");
assert_eq!(a.source[at], None, "the new entry is the one conversion");
for j in 0..at {
assert_eq!(a.source[j], Some(j), "the head is untouched");
}
for j in at + 1..now.len() {
assert_eq!(
a.source[j],
Some(j - 1),
"entry {j} keeps the quads it had at {}, one place back",
j - 1
);
}
}
/// The mirror: an entry vanishes, nothing converts at all, and the tail keeps its quads from
/// one place forward. The splice must still run — the quads have to leave the list.
#[test]
fn a_deletion_converts_nothing_and_still_has_work() {
let was = entries();
let at = was.len() / 2;
let mut now = was.clone();
now.remove(at);
let a = align_entries(&was, &now);
assert_eq!(
a.dropped,
vec![at],
"the vanished entry's quads are dropped"
);
assert!(
a.source.iter().all(Option::is_some),
"a deletion converts nothing"
);
for j in at..now.len() {
assert_eq!(a.source[j], Some(j + 1), "the tail shifts back one");
}
}
/// Two lists with nothing in common align to nothing kept — the bound above this call is
/// what then sends the frame to the full conversion.
#[test]
fn a_wholly_new_list_keeps_nothing() {
let was = entries();
let now: Vec<ExtractedQuad> = was
.iter()
.cloned()
.map(|mut e| {
e.z += 1;
e
})
.collect();
let a = align_entries(&was, &now);
assert!(a.source.iter().all(Option::is_none));
assert_eq!(a.dropped.len(), was.len());
}
}
#[cfg(test)]
mod extract_gate_tests {
use bevy::prelude::*;
@@ -1974,6 +2427,105 @@ mod extract_gate_tests {
);
}
/// **A frame appearing shifts every entry behind it, and the splice sees through that**
/// (decision 1638). Showing a hidden sibling inserts its entries into the MIDDLE of the
/// render list, so every entry after them lands at a new index. Compared index-wise they all
/// looked changed and the frame took the full conversion — which is what 86 % of hover frames
/// were doing. Both directions, and both must equal the full conversion of the same model.
#[test]
fn a_shown_sibling_splices_through_the_shift_and_matches_the_full_conversion() {
// A, B, C in declaration order, so B's entries sort between A's and C's: C is the tail
// that shifts. B starts hidden and contributes nothing.
const BUILD: &str = r#"
local function box(name, x, r, g, b)
local f = CreateFrame("Frame", name)
f:SetPoint("TOPLEFT", x, 0)
f:SetSize(50, 50)
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(r, g, b)
t:SetAllPoints()
return f
end
box("A", 0, 1, 0, 0)
hidden = box("B", 60, 0, 1, 0)
box("C", 120, 0, 0, 1)
"#;
let mut app = app_from_script(&format!("{BUILD}\nhidden:Hide()"));
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.update();
let without = app.world().resource::<UiQuads>().quads.len();
app.world_mut().resource_mut::<UiQuads>().dirty = false;
// ── Insertion ────────────────────────────────────────────────────────────────────────
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("hidden:Show()")
.unwrap();
app.update();
let cost = app.world().resource::<crate::ui_script::UiFrameCost>();
assert!(
cost.spliced > 0 && cost.dropped == 0,
"B's entries are the only conversions and nothing left the list \
(spliced={}, dropped={})",
cost.spliced,
cost.dropped
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a frame appearing is a real change");
assert!(
quads.quads.len() > without,
"the shown frame's quad actually joined the list ({} -> {})",
without,
quads.quads.len()
);
// The reference must share the app's HISTORY, not just its end state: `Show()` on a
// hidden frame re-stacks it to the tail of its draw bucket — the client's own
// `effective_visible_show 0x76ae10` re-adding it to the level's intrusive list — so B
// draws above C afterwards. (The splice got that right on its own; this reference did
// not, which is how the difference surfaced.)
let mut reference = app_from_script(&format!("{BUILD}\nhidden:Hide()\nhidden:Show()"));
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"the spliced list equals the full conversion of the same model"
);
// ── Deletion ─────────────────────────────────────────────────────────────────────────
// The mirror, and the reason `dropped` exists: nothing converts at all, so `spliced` is
// zero on a frame that most certainly did splice.
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("hidden:Hide()")
.unwrap();
app.update();
let cost = app.world().resource::<crate::ui_script::UiFrameCost>();
assert_eq!(cost.spliced, 0, "a pure deletion converts nothing");
assert!(
cost.dropped > 0,
"and it is the drop count that proves it spliced"
);
let quads = app.world().resource::<UiQuads>();
assert!(quads.dirty, "a vanished frame is a real change");
assert_eq!(
quads.quads.len(),
without,
"the list is back to what it was before B appeared"
);
let mut reference = app_from_script(&format!(
"{BUILD}\nhidden:Hide()\nhidden:Show()\nhidden:Hide()"
));
reference.update();
assert!(
app.world().resource::<UiQuads>().quads
== reference.world().resource::<UiQuads>().quads,
"the post-deletion list equals the full conversion of the same model"
);
}
/// A raster-environment change (window resize) must pin the conversion to the FULL path —
/// the splice's held quads were rasterized under the old seam and every one of them is
/// wrong-sized (decision 1342's edge, applied to the splice).
+36
View File
@@ -154,6 +154,11 @@ pub(crate) struct UiFrameCost {
/// full-conversion frame. Nonzero is the proof the splice path fired (the equivalence tests
/// and the live `[ui-cost] spliced=` field both read it).
pub(crate) spliced: usize,
/// How many entries the splice *dropped* — drew last frame and does not draw now. A frame
/// that only closes something has `spliced == 0` and still rode the splice, so this is the
/// other half of "did the splice path fire" (decision 1638). Not a CSV column: the recorder's
/// row is the phase timings, and `[ui-cost] dropped=` already carries it inline.
pub(crate) dropped: usize,
}
/// Does anything want [`UiFrameCost`] filled in this run? Measuring the split costs a clock read
@@ -585,6 +590,19 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
// it that this seed never fed, so the capture quietly stopped showing the "5" it exists to
// prove (found while fixing decision 1301).
script.set_bonus_bar_offset(1);
// One MACRO slot too (button 4): a GM-style macro that casts nothing, so the capture
// shows the macro-name line under the icon and the icon full-colour — B340's shape
// (decision 1636). The table is seeded here because a capture has no character identity
// for `ui_macro::load_macros` to read a file for; nothing overwrites it.
script.set_macros(benilla_ui::script::MacroState {
account: vec![benilla_ui::script::MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
for (action, icon, kind, id, count) in [
(
73,
@@ -601,6 +619,13 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
102,
0,
),
(
76,
"Interface\\Icons\\Ability_Racial_Cannibalize",
0x40,
1,
0,
),
(80, "Interface\\Icons\\INV_Misc_Food_16", 0x80, 117, 5),
(84, "Interface\\Icons\\Spell_Holy_SealOfMight", 0x00, 103, 0),
// The always-on multibars (MultiBars.xml): BottomLeft = actions 61..72, BottomRight
@@ -636,6 +661,17 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
consumable: kind == 0x80,
}),
);
// The state feed has nothing to feed server-less (no `PlayerActions`), and a slot
// with no pushed state answers `IsUsableAction` nil — the 0.4 grey on every icon,
// which the `ui-actionbar` baseline wore for as long as it existed. Stand in for it
// as this feed stands in for the unit feed: a live bar's resting state is usable.
script.set_action_state(
action,
Some(benilla_ui::script::ActionState {
usable: true,
..Default::default()
}),
);
}
// The stance bar (StanceBar.xml): the synthetic warrior's three stances, battle active —
// matches the bonus offset 1 above (battle stance page). Defensive shows the not-castable
@@ -1136,14 +1136,15 @@ fn the_multisampling_row_offers_exactly_what_the_device_supports() {
"Multisampling",
"the reference's own MULTISAMPLE global"
);
// "1" is the registered default and means NO multisampling — the first stop, shown in the
// reference's own MULTISAMPLING_FORMAT_STRING wording.
// "1" is the registered default and means NO multisampling, so the first stop reads "Off"
// rather than "1x" (1635). The label carries the noun; the capsule carries the value.
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
)
.unwrap(),
"32-bit color 32-bit depth 1x multisample"
"Off",
"1 is the encoding of NO multisampling, not one sample of it"
);
assert!(
s.take_cvar_changes().is_empty(),
@@ -1161,7 +1162,7 @@ fn the_multisampling_row_offers_exactly_what_the_device_supports() {
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"32-bit color 32-bit depth 4x multisample"
"4x"
);
// Picking 4x writes the CVar — live and persisted, even though the picture waits for the next
@@ -1176,7 +1177,7 @@ fn the_multisampling_row_offers_exactly_what_the_device_supports() {
"return OptionsFrameContainerBodyGraphicsRowMultisampleDropdownText:GetText()"
)
.unwrap(),
"32-bit color 32-bit depth 4x multisample"
"4x"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+7 -3
View File
@@ -725,11 +725,15 @@ impl UiScript {
// Snapshotted only for frames that actually CARRY a handler (a handful, usually zero), and
// only past the tier-1 gate above — an idle frame returns before this line, so the change
// costs nothing on the quiet path decision 0740 exists to protect.
//
// Read off `SetScript`'s maintained list (1446's shape, extended in 1634), NOT by filtering
// the whole `scripts` map: "a handful, usually zero" was true of the RESULT and never of
// the search, which cost a string compare per frame in a 3,988-frame roster and was the
// preamble's biggest phase — larger than everything the graph does with the answer.
let watched: Vec<(FrameHandle, Option<Rect>)> = model
.scripts
.on_size_changed_frames
.iter()
.filter(|(_, kinds)| kinds.contains("OnSizeChanged"))
.map(|(&h, _)| (h, model.resolved.get(&h).copied()))
.map(|&h| (h, model.resolved.get(&h).copied()))
.collect();
pre.watched = pre.lap();
// ── The graph: the ledger's cache, or a fresh derivation ─────────────────────────────
+8
View File
@@ -263,6 +263,13 @@ pub(crate) struct Model {
/// irrelevant here: the tick sorts its visible subset by frame id every pass (the
/// deterministic-dispatch law stays where it was).
pub(crate) on_update_frames: Vec<FrameHandle>,
/// The frames carrying an `OnSizeChanged` script — 1446's list, for the layout resolve's
/// "before" snapshot instead of the tick. Same one writer, same reason: that snapshot's own
/// comment says it covers "a handful, usually zero", and it found them by filtering the WHOLE
/// `scripts` map with a string compare per entry, on every let-through resolve — 36–72 µs a
/// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble
/// (decision 1634's `[layout-pre] watched=`).
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// `event name → frames registered for it` (RegisterEvent), in **registration order** — an
/// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener
/// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line
@@ -1390,6 +1397,7 @@ impl Model {
region_names: HashMap::new(),
scripts: HashMap::new(),
on_update_frames: Vec::new(),
on_size_changed_frames: Vec::new(),
event_to_frames: HashMap::new(),
frame_events: HashMap::new(),
focused_editbox: None,
@@ -360,16 +360,26 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
let mut model = lua.app_data_mut::<Model>().expect("model");
// `insert` answering true = this frame did not have the kind before — the tick's
// OnUpdate list rides that edge (decision 1446; `scripts` has no other writer).
if model.scripts.entry(h).or_default().insert(kind) && kind == "OnUpdate" {
model.on_update_frames.push(h);
if model.scripts.entry(h).or_default().insert(kind) {
match kind {
"OnUpdate" => model.on_update_frames.push(h),
"OnSizeChanged" => model.on_size_changed_frames.push(h),
_ => {}
}
}
}
None => {
per.set(kind, Value::Nil)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(set) = model.scripts.get_mut(&h) {
if set.remove(&kind) && kind == "OnUpdate" {
model.on_update_frames.retain(|&x| x != h);
if set.remove(&kind) {
match kind {
"OnUpdate" => model.on_update_frames.retain(|&x| x != h),
"OnSizeChanged" => {
model.on_size_changed_frames.retain(|&x| x != h);
}
_ => {}
}
}
}
}
@@ -159,3 +159,54 @@ fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() {
s.resolve();
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
}
/// **The watch list is exactly the frames carrying the script.** The resolve's "before" snapshot
/// reads `SetScript`'s maintained `on_size_changed_frames` rather than filtering the whole scripts
/// map (decision 1634), so the list itself is now load-bearing: `SetScript(…, nil)` must remove
/// the frame, and re-registering must not enrol it twice.
///
/// Asserted on the LIST, not on the fire count, and that is the point. A stale entry fires nothing
/// — `fire` looks the handler up in Lua and finds nil — so behaviour hides the bug completely and
/// the only symptom is the snapshot silently growing forever, one dead frame at a time. (Written
/// as a fire-count test first; it passed with the `retain` deleted.)
#[test]
fn the_on_size_changed_watch_list_is_exactly_the_frames_carrying_the_script() {
use crate::script::model::Model;
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires = 0
Watched = CreateFrame("Frame", "WatchedPanel")
Watched:SetPoint("BOTTOMLEFT", 0, 0)
Watched:SetSize(10, 10)
Other = CreateFrame("Frame", "UnwatchedPanel")
Other:SetScript("OnShow", function() end) -- another kind never enrols
local bump = function() fires = fires + 1 end
Watched:SetScript("OnSizeChanged", bump)
Watched:SetScript("OnSizeChanged", bump) -- re-registering is not a second enrolment
"#,
)
.unwrap();
let watched = |s: &crate::script::UiScript| {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.on_size_changed_frames
.len()
};
assert_eq!(watched(&s), 1, "one frame carries the script");
s.resolve();
s.run("Watched:SetSize(40, 40)").unwrap();
s.resolve();
assert_eq!(
s.eval::<i64>("return fires").unwrap(),
2,
"one fire per resolve whose size moved — the 0×0→10×10 birth, then the resize"
);
s.run(r#"Watched:SetScript("OnSizeChanged", nil)"#).unwrap();
assert_eq!(watched(&s), 0, "clearing the script un-watches the frame");
}
+16 -9
View File
@@ -671,16 +671,16 @@ fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
/// frames whose lines haven't been measured yet (the XML default size holds until the measure
/// round-trip lands, one frame later — the same convergence the Lua loop had).
pub(super) fn layout_tooltips(model: &mut Model) {
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
// of what it cost (decision 1634). `frame_to_id` still gates each one — a tooltip outside the
// resolve's roster would mint an id here and take the ledger's conservative branch with it.
let tips: Vec<FrameHandle> = model
.frame_to_id
.keys()
.arena
.tooltip_kinds()
.iter()
.copied()
.filter(|&h| {
matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(_))
)
})
.filter(|h| model.frame_to_id.contains_key(h))
.collect();
for h in tips {
let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) {
@@ -767,7 +767,14 @@ pub(super) fn tick_fades(lua: &Lua) {
let mut finished: Vec<FrameHandle> = Vec::new();
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
for &h in model.frame_to_id.keys() {
// The arena's tooltip registry (1634) — this runs every tick, and the roster it used to
// walk is ~4000 entries at a corpus UI. The `frame_to_id` gate is kept: `hide_tooltip`
// below writes layout, and a frame outside the resolve's roster takes the ledger's
// conservative branch.
for &h in model.arena.tooltip_kinds() {
if !model.frame_to_id.contains_key(&h) {
continue;
}
let Some(frame) = model.arena.frame(h) else {
continue;
};
+10 -6
View File
@@ -262,15 +262,19 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
let hits: Vec<FrameHandle> = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
// The arena's tooltip registry (1634), gated by the resolve's roster exactly as the walk
// it replaces was — `update_bar` writes layout.
model
.frame_to_id
.keys()
.arena
.tooltip_kinds()
.iter()
.copied()
.filter(|&h| {
matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
)
model.frame_to_id.contains_key(&h)
&& matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
)
})
.collect()
};
+17
View File
@@ -411,6 +411,13 @@ pub struct WidgetArena {
/// corpus UI is thousands of frames. Appended at creation (kinds never change after);
/// `destroy` removes its own handle, so the list never carries dead entries.
ticked_kinds: Vec<FrameHandle>,
/// Every live **GameTooltip**, same registry shape as [`Self::ticked_kinds`] and for the same
/// reason one layer over: three hot paths (the auto-size pre-pass at the top of every layout
/// resolve, the per-tick fade advance, and the unit-push hook) all begin "find the tooltips",
/// and all three did it by walking the resolve's whole frame roster — ~4000 entries at a
/// corpus UI, to reach the two or three that exist. Appended at creation (kinds never change);
/// `destroy` removes its own handle, so the list never carries dead entries.
tooltip_kinds: Vec<FrameHandle>,
}
impl Default for WidgetArena {
@@ -429,6 +436,7 @@ impl WidgetArena {
next_insertion: 0,
minimap_created: 0,
ticked_kinds: Vec::new(),
tooltip_kinds: Vec::new(),
}
}
@@ -442,6 +450,11 @@ impl WidgetArena {
&self.ticked_kinds
}
/// The live GameTooltip frames (see the field note).
pub fn tooltip_kinds(&self) -> &[FrameHandle] {
&self.tooltip_kinds
}
// ── Read access ────────────────────────────────────────────────────────────────────────────
/// Borrow a frame, or `None` if the handle is stale/unknown.
@@ -652,6 +665,9 @@ impl WidgetArena {
if ticked {
self.ticked_kinds.push(handle);
}
if matches!(kind, FrameKind::GameTooltip) {
self.tooltip_kinds.push(handle);
}
if let Some(p) = parent {
self.frame_mut(p)
@@ -678,6 +694,7 @@ impl WidgetArena {
let name = frame.name.clone();
// Each (recursive) destroy removes its own handle — the tick registry stays dead-free.
self.ticked_kinds.retain(|&t| t != h);
self.tooltip_kinds.retain(|&t| t != h);
for c in children {
self.destroy(c);
+26
View File
@@ -272,3 +272,29 @@ fn set_alpha_overwrites_the_subtree() {
let late = a.create(FrameKind::Frame, None, Some(root));
assert_eq!(a.frame(late).unwrap().effective_alpha, 1.0);
}
// ── The per-kind registries ──────────────────────────────────────────────────────────────────
/// The tooltip registry is exactly the live GameTooltips — including when one dies as somebody
/// else's child, which is the case a registry maintained only at the explicit `destroy` call site
/// would miss. Three hot paths read it instead of scanning the resolve's roster (decision 1634), so
/// a stale entry is a dangling handle in the layout pre-pass, not a cosmetic drift.
#[test]
fn the_tooltip_registry_tracks_live_gametooltips() {
let mut a = arena();
assert!(a.tooltip_kinds().is_empty());
let holder = a.create(FrameKind::Frame, None, None);
let loose = a.create(FrameKind::GameTooltip, None, None);
let child = a.create(FrameKind::GameTooltip, None, Some(holder));
// A non-tooltip kind never enters the list.
let _plain = a.create(FrameKind::Button, None, None);
assert_eq!(a.tooltip_kinds(), &[loose, child]);
// Destroying the PARENT takes its tooltip child with it.
a.destroy(holder);
assert_eq!(a.tooltip_kinds(), &[loose]);
a.destroy(loose);
assert!(a.tooltip_kinds().is_empty());
}