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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user