signpost tooltips survive the map, and the quest log stops blanking

- a signpost or mailbox tooltip shows with Questie loaded, and after opening the map: the cursor-seated plate carries an owner, so Questie's OnShow hook no longer hides it (2255)
- picking up a quest whose template is not cached no longer blanks the quest log or jumps the selection: the missing quest is skipped, not the pass (2256)
- the spellbook's skill-line tab flashes when a spell lands in it (2252)
- the GPU meter's resolve rides the frame's own encoder, with no submit or poll of its own: −1.8 ms on Intel's DX12 driver when armed (2254)
- the audio starvation watch no longer charges a stream's own spin-up as silence (2253)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 17:02:24 +07:00
co-authored by Claude Fable 5.1
parent f2377ec846
commit 5c9d919a0c
16 changed files with 1492 additions and 186 deletions
+6
View File
@@ -356,6 +356,10 @@ pub(crate) fn apply_net_updates(
// and this is where the room is. One bundle rather than three `Res`, so the use sites
// read `cvars.ranges` instead of `ui_actions.1 .7`.
crate::ui_chat::combat::CombatFeedbackCvars<'_>,
// Spells learned mid-session, awaiting `LEARNED_SPELL_IN_TAB` (2252). A queue for the
// same reason the reference sorts before it fires: the tab index is only correct
// against the rebuilt tab list, which is the spellbook feed's, not this apply's.
ResMut<crate::ui_spellbook::LearnedInTab>,
),
ResMut<crate::ui_items::EquipErrors>,
ResMut<crate::ui_merchant::MerchantErrors>,
@@ -1146,6 +1150,7 @@ pub(crate) fn apply_net_updates(
&mut ui_actions.0,
ui_actions.10.as_deref(),
&mut ui_error_keys,
&mut ui_actions.1 .6,
),
SessionEvent::SpellRemoved { spell_id } => removed_spell(
spell_id,
@@ -1162,6 +1167,7 @@ pub(crate) fn apply_net_updates(
&mut ui_actions.0,
ui_actions.10.as_deref(),
&mut ui_error_keys,
&mut ui_actions.1 .6,
),
SessionEvent::CastResult {
spell_id,
+52 -5
View File
@@ -14,6 +14,7 @@ use crate::creature_anim::{CastEvent, CastEventKind, Casting, SpellGoTargets};
use crate::ui_action::{AutoRepeatActive, CastErrors, PlayerActions, Spells, UiError, UiErrorKeys};
use crate::ui_aura::AuraDurations;
use crate::ui_cast::{ActiveChannel, CastBarEdge, CastBarFeed, PendingCast, QueuedMeleeSpell};
use crate::ui_spellbook::LearnedInTab;
use super::super::{GuidIndex, ObjectStore, SelfGuid};
@@ -60,12 +61,14 @@ pub(super) fn learned_spell(
actions: &mut PlayerActions,
spells: Option<&Spells>,
errors: &mut UiErrorKeys,
tab_flash: &mut LearnedInTab,
) {
debug!("net: learned spell {spell_id}");
if actions.spells.insert(spell_id) {
actions.dirty = true;
}
announce_learn(spell_id, spells, errors);
tab_flash.0.push(spell_id);
}
/// **The learn announcement** — the `ERR_LEARN_*` chat line the registrar `0x4b25b0` prints at
@@ -162,6 +165,7 @@ pub(super) fn superceded_spell(
actions: &mut PlayerActions,
spells: Option<&Spells>,
errors: &mut UiErrorKeys,
tab_flash: &mut LearnedInTab,
) {
debug!("net: superceded spell {old_spell_id} -> {new_spell_id}");
actions.spells.remove(&old_spell_id);
@@ -171,6 +175,9 @@ pub(super) fn superceded_spell(
// calls the unlearn `0x4b2c50` and then the registrar `0x4b25b0` with `mov edx,0x1`
// (`0x4b2f61`), and the unlearn half holds no `DisplayError` call at all (decision 2243).
announce_learn(new_spell_id, spells, errors);
// The rank-up reaches the registrar with the same flag set (`0x4b2f61 mov edx,0x1`), so it
// flashes the tab too — for the NEW rank, whose tab is the one it lands in (2252).
tab_flash.0.push(new_spell_id);
}
/// The server's verdict on our cast (`SMSG_CAST_RESULT`).
@@ -1121,11 +1128,14 @@ mod tests {
);
let mut actions = PlayerActions::default();
let mut errors = UiErrorKeys::default();
learned_spell(78, &mut actions, Some(&spells), &mut errors);
let mut flash = LearnedInTab::default();
learned_spell(78, &mut actions, Some(&spells), &mut errors, &mut flash);
assert_eq!(errors.0.len(), 1, "one line, once");
assert_eq!(errors.0[0].key, "ERR_LEARN_ABILITY_S");
assert_eq!(errors.0[0].arg_s(), Some("Heroic Strike (Rank 1)"));
// The same live-mutation flag gates the tab flash, so the queue takes it too (2252).
assert_eq!(flash.0, vec![78], "queued for LEARNED_SPELL_IN_TAB");
}
/// The other two arms of the same block, from the same entry point: a plain row is a *spell*,
@@ -1148,6 +1158,7 @@ mod tests {
&mut PlayerActions::default(),
Some(&spells),
&mut errors,
&mut LearnedInTab::default(),
);
assert_eq!(errors.0[0].key, "ERR_LEARN_SPELL_S");
assert_eq!(errors.0[0].arg_s(), Some("Fireball (Rank 1)"));
@@ -1167,6 +1178,7 @@ mod tests {
&mut PlayerActions::default(),
Some(&spells),
&mut errors,
&mut LearnedInTab::default(),
);
assert_eq!(errors.0[0].key, "ERR_LEARN_RECIPE_S");
assert_eq!(
@@ -1209,9 +1221,22 @@ mod tests {
let mut actions = PlayerActions::default();
actions.spells.insert(78);
let mut errors = UiErrorKeys::default();
superceded_spell(78, 284, &mut actions, Some(&spells), &mut errors);
let mut flash = LearnedInTab::default();
superceded_spell(
78,
284,
&mut actions,
Some(&spells),
&mut errors,
&mut flash,
);
assert_eq!(errors.0.len(), 1, "one line for a rank-up, not two");
assert_eq!(
flash.0,
vec![284],
"the NEW rank's tab flashes, not the old one's"
);
assert_eq!(errors.0[0].key, "ERR_LEARN_ABILITY_S");
assert_eq!(errors.0[0].arg_s(), Some("Heroic Strike (Rank 2)"));
}
@@ -1235,12 +1260,14 @@ mod tests {
&mut PlayerActions::default(),
Some(&spells),
&mut errors,
&mut LearnedInTab::default(),
);
learned_spell(
99999,
&mut PlayerActions::default(),
Some(&spells),
&mut errors,
&mut LearnedInTab::default(),
);
assert!(
errors.0.is_empty(),
@@ -1252,12 +1279,24 @@ mod tests {
fn learned_spell_adds_to_the_book_once() {
let mut actions = PlayerActions::default();
let mut errors = UiErrorKeys::default();
learned_spell(6603, &mut actions, None, &mut errors);
learned_spell(
6603,
&mut actions,
None,
&mut errors,
&mut LearnedInTab::default(),
);
assert!(actions.spells.contains(&6603));
assert!(actions.dirty, "a new spell dirties the feed");
actions.dirty = false;
learned_spell(6603, &mut actions, None, &mut errors);
learned_spell(
6603,
&mut actions,
None,
&mut errors,
&mut LearnedInTab::default(),
);
assert!(
!actions.dirty,
"re-learning a known spell is a no-op (insert returns false)"
@@ -1279,7 +1318,14 @@ mod tests {
},
);
superceded_spell(78, 284, &mut actions, None, &mut UiErrorKeys::default());
superceded_spell(
78,
284,
&mut actions,
None,
&mut UiErrorKeys::default(),
&mut LearnedInTab::default(),
);
assert!(
!actions.spells.contains(&78),
@@ -2129,6 +2175,7 @@ mod tests {
&mut PlayerActions::default(),
Some(&spells),
&mut errors,
&mut LearnedInTab::default(),
);
assert_eq!(errors.0.len(), 1, "the learn block never reads castUI");
assert_eq!(errors.0[0].key, "ERR_LEARN_SPELL_S");
+102 -45
View File
@@ -8,14 +8,24 @@
//! bevy's own `RenderDiagnosticsPlugin` needs INSIDE_PASSES and reads zero GPU spans here — the
//! `perf` module header's ⚠ note, measured in 1389).
//!
//! **The resolve rides its own submission, one frame later.** 1389's recorded trap: resolving
//! the query set in the SAME command buffer as the timed work returns zeros on Metal (5 of 6
//! reads; the 6th a plausible-looking near-miss). So the cleanup system resolves last frame's
//! set into a small ring of MAP_READ buffers via a fresh encoder, and reads the mapping two
//! frames behind. The published number is the freshest mapped delta, nanoseconds in one
//! `AtomicU64` shared with the main world — `FPS_PROBE` samples it per frame under the same env
//! and prints its own percentiles, so a leg gets `gpu_p50/gpu_p99` beside `cpu_ms` with zero
//! cost when the env is off (nothing registers).
//! **The resolve rides the NEXT frame's command buffer, ahead of that frame's own stamps.**
//! 1389's recorded trap: resolving the query set in the SAME command buffer as the timed work
//! returns zeros on Metal (5 of 6 reads; the 6th a plausible-looking near-miss) — resolve on a
//! later submission. The first build of this meter did that with a submission of its own: a
//! fresh encoder, a second `queue.submit` and an explicit `device.poll` in `Cleanup`, every
//! frame — a per-frame poll that traced at 1.8 ms on Intel's DX12 driver (2236) and priced
//! untraced in 2254. So the query set holds TWO stamp pairs and the frames alternate between
//! them ([`pairs`]): the `begin` node first resolves the pair the previous frame wrote — a later
//! submission, 1389's rule kept — and copies it into a free slot of a small ring of MAP_READ
//! buffers, on the graph's own shared encoder; then it writes this frame's pair. `Cleanup` only
//! kicks the map on the slot the graph just filled, and that map lands inside the NEXT frame's
//! own `queue.submit`, which runs wgpu's device maintenance as part of every submit
//! (`wgpu_core::device::queue::Queue::submit` ends in `Device::maintain(Poll)`) — the shape of
//! bevy's own `DiagnosticsRecorder`, and no submission or poll of the meter's own. The
//! published number is the freshest mapped delta, nanoseconds in one `AtomicU64` shared with
//! the main world — `FPS_PROBE` samples it per frame under the same env and prints its own
//! percentiles, so a leg gets `gpu_p50/gpu_p99` beside `cpu_ms` with zero cost when the env is
//! off (nothing registers).
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
@@ -29,13 +39,11 @@ use bevy::render::render_graph::{
Node, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel,
};
use bevy::render::render_phase::{ViewBinnedRenderPhases, ViewSortedRenderPhases};
use bevy::render::render_resource::{
Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, MapMode,
};
use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages, MapMode};
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::{Render, RenderApp, RenderSystems};
use bevy::ui_render::TransparentUi;
use wgpu::{PollType, QuerySet, QuerySetDescriptor, QueryType};
use wgpu::{QuerySet, QuerySetDescriptor, QueryType};
/// Is the meter armed? One read; everything registers behind it.
pub(crate) fn enabled() -> bool {
@@ -167,7 +175,19 @@ const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// Render-world state: the query set, its resolve buffer, and the mapping ring.
/// Which stamp pair a graph run writes and which it resolves, for the [`GpuStamp::frame`] it
/// runs under: the two pairs alternate, and a run resolves the pair the run before it wrote —
/// a later submission than the timed work (1389's rule), and one whose queries the previous
/// command buffer reset before writing (a resolve of a never-reset query is 2211's hang). Pair
/// `p` is queries `2p` (begin) and `2p + 1` (end).
fn pairs(frame: usize) -> (u32, u32) {
#[allow(clippy::cast_possible_truncation)]
let write = (frame % 2) as u32;
(write, 1 - write)
}
/// Render-world state: the query set (two stamp pairs, alternating by frame), the resolve
/// buffer, and the mapping ring.
#[derive(Resource)]
struct GpuStamp {
query_set: QuerySet,
@@ -176,16 +196,23 @@ struct GpuStamp {
sentinel_view: wgpu::TextureView,
resolve: Buffer,
ring: Vec<(Buffer, Arc<AtomicU8>)>,
/// Frames this resource has seen a `readback` for. **Zero means the stamps have never been
/// written**: `init_stamp` inserts the resource in `Cleanup`, the scheduler's sync point
/// applies it before `readback` runs in the same set, and the sentinel nodes that write —
/// and, in wgpu, reset — the two queries only run in the NEXT frame's graph. A resolve on
/// that first pass reads a query pool no command has ever reset, which Vulkan forbids
/// (`VUID-vkCmdCopyQueryPoolResults-None-09402`) and answers, on NVIDIA's driver, by
/// waiting on it forever — a GPU hang, the TDR, and the lost device 2205 met at the
/// swapchain acquire. Metal hands back zeros for the same read, which is why the meter's
/// first live runs (1389) saw "zeros" and never a hang. Decision 2211.
/// `Cleanup`s this resource has seen, counting the one that created it; the graph run that
/// follows each writes pair `frame % 2` and resolves the other ([`pairs`]). **The creation
/// `Cleanup` precedes the first stamped frame**: `init_stamp` inserts the resource in
/// `Cleanup`, the scheduler's sync point applies it before `readback` runs in the same set,
/// and the sentinel nodes that write — and, in wgpu, reset — the queries only run in the
/// NEXT frame's graph. A resolve on that first run would read a query pool no command has
/// ever reset, which Vulkan forbids (`VUID-vkCmdCopyQueryPoolResults-None-09402`) and
/// answers, on NVIDIA's driver, by waiting on it forever — a GPU hang, the TDR, and the
/// lost device 2205 met at the swapchain acquire. Metal hands back zeros for the same read,
/// which is why the meter's first live runs (1389) saw "zeros" and never a hang. Decision
/// 2211. So the first run is armed with no [`Self::slot`] and resolves nothing.
frame: usize,
/// The ring slot the NEXT graph run copies the previous run's stamps into: chosen in
/// `Cleanup` from the FREE slots — `None` while no run has written a pair yet, or while the
/// ring is full (that sample is skipped; the meter is a meter). The `begin` node reads it,
/// the `Cleanup` after that run maps it.
slot: Option<usize>,
period: f32,
shared: Arc<AtomicU64>,
}
@@ -207,14 +234,30 @@ impl Node for GpuStampNode {
world: &World,
) -> Result<(), NodeRunError> {
if let Some(stamp) = world.get_resource::<GpuStamp>() {
let (write, resolve) = pairs(stamp.frame);
if self.index == 0 {
if let Some(slot) = stamp.slot {
// The previous run's pair, resolved and copied out on the graph's own
// encoder: a later submission than the stamps it reads (1389), and none of
// the meter's own.
let encoder = render_context.command_encoder();
encoder.resolve_query_set(
&stamp.query_set,
resolve * 2..resolve * 2 + 2,
&stamp.resolve,
0,
);
encoder.copy_buffer_to_buffer(&stamp.resolve, 0, &stamp.ring[slot].0, 0, 16);
}
}
// A SENTINEL PASS, not a bare `write_timestamp`: Apple Silicon samples counters only
// at stage boundaries, and an encoder-level stamp with no stages around it resolves
// to zero (measured on this meter's first live run — 1389's "two sentinel nodes"
// meant exactly this). An empty compute pass is the cheapest stage boundary there is.
let (begin, end) = if self.index == 0 {
(Some(0), None)
(Some(write * 2), None)
} else {
(None, Some(1))
(None, Some(write * 2 + 1))
};
render_context
.command_encoder()
@@ -261,7 +304,7 @@ fn init_stamp(
let query_set = device.wgpu_device().create_query_set(&QuerySetDescriptor {
label: Some("gpu-ms stamps"),
ty: QueryType::Timestamp,
count: 2,
count: 4,
});
let resolve = device.create_buffer(&BufferDescriptor {
label: Some("gpu-ms resolve"),
@@ -304,40 +347,32 @@ fn init_stamp(
resolve,
ring,
frame: 0,
slot: None,
period: queue.get_timestamp_period(),
shared: shared.0.clone(),
});
}
/// After the graph's own submission: resolve LAST frame's stamps on a fresh encoder (its own
/// command buffer — the 1389 trap), kick the map, and publish the oldest mapped delta.
fn readback(stamp: Option<ResMut<GpuStamp>>, device: Res<RenderDevice>, queue: Res<RenderQueue>) {
/// `Cleanup`, after the graph's own submission: kick the map on the slot the graph just filled,
/// publish every landed mapping, and arm a slot for the next run. No encoder, no submit and no
/// poll of the meter's own — the map lands in the next frame's `queue.submit`, which maintains
/// the device (the module header).
fn readback(stamp: Option<ResMut<GpuStamp>>) {
let Some(mut stamp) = stamp else { return };
// The first cleanup after creation precedes the first stamped frame: nothing to resolve
// yet, and resolving anyway is the Vulkan device loss above (see `frame`).
if stamp.frame == 0 {
stamp.frame = 1;
return;
}
// Copy into a FREE slot only (none free → skip this frame's sample; the meter is a meter).
if let Some(i) = (0..RING).find(|&i| stamp.ring[i].1.load(Ordering::Relaxed) == FREE) {
let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("gpu-ms resolve"),
});
encoder.resolve_query_set(&stamp.query_set, 0..2, &stamp.resolve, 0);
encoder.copy_buffer_to_buffer(&stamp.resolve, 0, &stamp.ring[i].0, 0, 16);
queue.submit([encoder.finish()]);
// The slot the graph run just copied into: FREE when armed, so that submission is the only
// one in flight on it, and it is out of the arming below until the mapping is read and
// released — submitting into a mapped or map-pending buffer is a wgpu validation error,
// which the first live run demonstrated.
if let Some(i) = stamp.slot.take() {
let flag = stamp.ring[i].1.clone();
flag.store(PENDING, Ordering::Relaxed);
let cb_flag = flag.clone();
stamp.ring[i]
.0
.slice(..)
.map_async(MapMode::Read, move |r| {
cb_flag.store(if r.is_ok() { MAPPED } else { FREE }, Ordering::Relaxed);
flag.store(if r.is_ok() { MAPPED } else { FREE }, Ordering::Relaxed);
});
}
let _ = device.poll(PollType::Poll);
// Publish every landed mapping; slots return to FREE.
for i in 0..RING {
if stamp.ring[i].1.load(Ordering::Relaxed) == MAPPED {
@@ -354,6 +389,11 @@ fn readback(stamp: Option<ResMut<GpuStamp>>, device: Res<RenderDevice>, queue: R
}
}
}
// Arm the next run — which resolves the pair the run that just finished wrote — unless no
// run has written one yet (the creation `Cleanup`; see `frame`). None FREE → no sample.
if stamp.frame >= 1 {
stamp.slot = (0..RING).find(|&i| stamp.ring[i].1.load(Ordering::Relaxed) == FREE);
}
stamp.frame += 1;
}
@@ -382,3 +422,20 @@ pub(crate) fn plugin(app: &mut App) {
graph.add_node_edge(GpuStampBeginLabel, CameraDriverLabel);
graph.add_node_edge(CameraDriverLabel, GpuStampEndLabel);
}
#[cfg(test)]
mod tests {
use super::pairs;
/// The ordering law the Vulkan hang (2211) and the Metal zeros (1389) both hang on: a run
/// never resolves the pair it writes, and always resolves the one the run before it wrote.
#[test]
fn a_run_resolves_the_pair_the_run_before_it_wrote() {
for frame in 1..12 {
let (write, resolve) = pairs(frame);
assert_ne!(write, resolve);
assert!(write < 2 && resolve < 2);
assert_eq!(resolve, pairs(frame - 1).0);
}
}
}
+385 -73
View File
@@ -732,6 +732,13 @@ impl symphonia::core::io::MediaSource for PromotingSource {
const STREAM_STARVED_MIN_SECS: f64 = 0.1;
/// The accounting window [`StreamWatch`] compares over.
const STREAM_WATCH_WINDOW_SECS: f64 = 1.0;
/// How long a stream may sit audible with its position still pinned where it was constructed
/// before the watch says it never played at all (decision 2253).
///
/// Well past any spin-up this machine produces — the login theme's is the worst of them, because
/// the output device opens in the same frame — and short enough that a cue which never arrives is
/// named while it should still be playing.
const STREAM_START_MAX_SECS: f64 = 3.0;
/// Position-freeze watch over a live stream — the starvation meter [`MixHealth`] lacks (decision
/// 1109; `poll_mix_health`'s docs name this exact blind spot).
@@ -743,6 +750,18 @@ const STREAM_WATCH_WINDOW_SECS: f64 = 1.0;
/// and WARNs when more than [`STREAM_STARVED_MIN_SECS`] went missing. Playback rate is always
/// 1.0 here (WoW pitches no music), so the two clocks agree to drift well under the threshold.
///
/// **Every stream opens with a frozen position, and that is not a dropout** (decision 2253).
/// kira's `Shared::new` publishes `state = Playing` and `position = 0.0` when `play()` returns, so
/// the handle reads audible before the renderer has ever seen the sound; and
/// `StreamingSound::process` opens with `if self.frame_consumer.slots() < 2 { out.fill(ZERO);
/// return; }` — it zero-fills *without advancing the position* until the decode thread has two
/// frames ready. The interval between `play()` and the first sample heard is therefore a frozen
/// position by construction, and it is spin-up latency (the cue begins a fraction of a second
/// late), never silence punched into audio that was already playing. So the watch does not start
/// counting until the position has actually moved ([`Phase::Starting`]), and says at `debug!` what
/// it skipped. Before that it charged the spin-up as loss: the 2026-09-15 login reported the glue
/// theme's own first ~235 ms as "injected silence", which is what a crackle does *not* sound like.
///
/// **Known blind spot (1112, deliberate):** the watch samples on the main thread, so a stream
/// that starves *during a main-thread stall* and reaches `Stopped` before the next frame is
/// never accounted — the window is discarded by the reset. Reconstructing it from the stop
@@ -751,32 +770,67 @@ const STREAM_WATCH_WINDOW_SECS: f64 = 1.0;
/// thread was doing.
pub(crate) struct StreamWatch {
label: &'static str,
last_pos: Option<f64>,
phase: Phase,
/// Wall time counted into the open window, summed from the fed frame deltas.
expected: f64,
/// Stream position gained over those same frames.
advanced: f64,
/// When the open window began, in WALL time — carried so the report can state the span it
/// actually covered beside the time it counted.
}
/// Where a watched stream is in its life: nothing to watch, spinning up, or playing.
#[derive(Clone, Copy)]
enum Phase {
/// No stream on the slot, or one that is not audible. Position legitimately holds.
Idle,
/// Audible, and the position has not moved off `pos` yet — the spin-up (see [`StreamWatch`]).
/// Nothing is counted here; `warned` holds [`STREAM_START_MAX_SECS`] to one line per stream.
Starting {
since: std::time::Instant,
pos: f64,
warned: bool,
},
/// Advancing. `window_start` is the WALL instant this window's counting began — stamped at
/// that same instant and only there, so the counted time and the reported span are two
/// measurements of one interval and *cannot* disagree.
///
/// **Because the two disagreed and nothing could say why.** A 2026-09-06 login closed a 1.0 s
/// window 0.756 s of wall after the stream started, which is arithmetically impossible if the
/// fed `dt` is the real frame delta — and the fix that put this watch on `Time<Real>` did not
/// change it. Rather than reason further from log timestamps, the line now carries both
/// numbers: if they disagree the caller's clock is still wrong, and if they agree the
/// starvation is real and the timestamps were the misreading.
window_start: Option<std::time::Instant>,
/// The closing window's counted totals, kept for the report (`observe` zeroes them).
closed: (f64, f64),
/// **They did disagree, across three runs, and that is why this is a field rather than an
/// `Instant::now()` taken in the report.** It used to be stamped lazily, on the first frame
/// that found `expected == 0.0` — which is the frame *after* the one the first delta reaches
/// back to, so the span dropped exactly one frame of the interval it claimed to cover. Two
/// 2026-09-06 logins called the result arithmetically impossible (a 1.0 s window closing
/// 0.81 s and 0.756 s of wall after the stream started) and suspected the fed clock; the
/// 2026-09-15 login that first carried the span printed `counted 1.00 s` over a `0.70 s`
/// window, and the missing 0.30 s is the 298 ms frame hitch stamped in that same instant.
/// The clock was right all along; the span was one frame short of what was counted.
Running {
last_pos: f64,
window_start: std::time::Instant,
},
}
/// What [`StreamWatch::observe`] found this frame. Returned rather than logged so the accounting
/// stays pure and the tests can drive it without a device.
enum Verdict {
/// A window closed having lost more than [`STREAM_STARVED_MIN_SECS`] of audible time.
Starved {
lost: f64,
counted: f64,
advanced: f64,
span: f64,
},
/// The stream went audible and its position never moved — nothing from it was ever heard.
NeverStarted { waited: f64 },
/// The stream began advancing this long after it went audible: its spin-up.
Began { after: f64 },
}
impl StreamWatch {
pub(crate) fn new(label: &'static str) -> Self {
Self {
label,
last_pos: None,
phase: Phase::Idle,
expected: 0.0,
advanced: 0.0,
window_start: None,
closed: (0.0, 0.0),
}
}
@@ -784,62 +838,149 @@ impl StreamWatch {
pub(crate) fn feed(&mut self, handle: &StreamingSoundHandle<FromFileError>, dt: f64) {
use kira::sound::PlaybackState as S;
let audible = matches!(handle.state(), S::Playing | S::Stopping);
if audible && self.expected == 0.0 {
self.window_start = Some(std::time::Instant::now());
}
if let Some(lost) = self.observe(audible, handle.position(), dt) {
let span = self
.window_start
.take()
.map_or(f64::NAN, |t| t.elapsed().as_secs_f64());
let (counted, advanced) = self.closed;
// **No cause is named.** This used to say "(decode thread outrun)", which is one of
// three mechanisms that freeze a stream's position identically — a starved decoder, a
// render thread that did not run, or a closed/rebuilding device — and the meter cannot
// tell them apart. Asserting one of the three in the line is how a log hands a reader
// a conclusion the instrument never reached.
warn!(
"audio: {} stream starved — ~{:.0} ms of injected silence over a {span:.2} s \
window (counted {counted:.2} s, position advanced {advanced:.2} s) — this is \
what a crackle sounds like",
match self.observe(audible, handle.position(), dt, std::time::Instant::now()) {
Some(Verdict::Starved {
lost,
counted,
advanced,
span,
}) => {
// **No cause is named.** This used to say "(decode thread outrun)", which is one
// of three mechanisms that freeze a stream's position identically — a starved
// decoder, a render thread that did not run, or a closed/rebuilding device — and
// the meter cannot tell them apart. Asserting one of the three in the line is how
// a log hands a reader a conclusion the instrument never reached.
warn!(
"audio: {} stream starved — ~{:.0} ms of injected silence over a {span:.2} s \
window (counted {counted:.2} s, position advanced {advanced:.2} s) — this is \
what a crackle sounds like",
self.label,
lost * 1000.0,
);
}
Some(Verdict::NeverStarted { waited }) => warn!(
"audio: {} has been audible {waited:.1} s with its position still at the start — \
not one sample of it has been heard",
self.label,
lost * 1000.0,
);
),
// A bound, not a stopwatch, and it says which bound: the clock starts when the WATCH
// first saw the handle (within a frame of `play()`, but that frame can be a long one
// during a login burst), and it can only stop on a frame, so a spin-up that ends
// inside a long frame is reported as that whole frame.
Some(Verdict::Began { after }) => debug!(
"audio: {} took {:.0} ms to start advancing after the watch first saw it — \
spin-up, not counted",
self.label,
after * 1000.0,
),
None => {}
}
}
/// Drop the baseline — call when the watched handle is dropped/replaced, so the next
/// stream's position (starting at 0, i.e. *behind* the old one's) can't read as a freeze.
pub(crate) fn reset(&mut self) {
self.last_pos = None;
self.phase = Phase::Idle;
self.expected = 0.0;
self.advanced = 0.0;
self.window_start = None;
}
/// The accounting core, pure so the tests below can drive it without a device. Returns
/// `Some(lost_secs)` when a window closes starved.
fn observe(&mut self, audible: bool, pos: f64, dt: f64) -> Option<f64> {
/// Open a counting window at `now` — which is both the position baseline and the wall origin.
/// The pair is set together on purpose; see [`Phase::Running`] for what happened when it
/// wasn't.
fn begin(&mut self, pos: f64, now: std::time::Instant) {
self.phase = Phase::Running {
last_pos: pos,
window_start: now,
};
self.expected = 0.0;
self.advanced = 0.0;
}
/// The accounting core, pure so the tests below can drive it without a device.
fn observe(
&mut self,
audible: bool,
pos: f64,
dt: f64,
now: std::time::Instant,
) -> Option<Verdict> {
// Non-audible states (stopped, paused) drop the baseline: position legitimately holds.
if !audible {
self.reset();
return None;
}
let Some(last) = self.last_pos.replace(pos) else {
return None; // first audible frame — baseline only
};
self.expected += dt;
// `max(0.0)`: a track replaced on the slot mid-window jumps backwards once (the new
// stream starts at 0); count it as no advance for that one frame, not as negative.
self.advanced += (pos - last).max(0.0);
if self.expected < STREAM_WATCH_WINDOW_SECS {
return None;
match self.phase {
Phase::Idle => {
self.phase = Phase::Starting {
since: now,
pos,
warned: false,
};
None
}
Phase::Starting {
since,
pos: start,
warned,
} => {
let waited = now.duration_since(since).as_secs_f64();
if pos > start {
self.begin(pos, now);
return Some(Verdict::Began { after: waited });
}
if !warned && waited > STREAM_START_MAX_SECS {
self.phase = Phase::Starting {
since,
pos: start,
warned: true,
};
return Some(Verdict::NeverStarted { waited });
}
None
}
Phase::Running {
last_pos,
window_start,
} => {
// A track swapped onto the slot mid-window starts at 0 — *behind* the one it
// replaced. That is a new stream, not a freeze, so it goes back to `Starting` and
// gets its own spin-up excluded exactly like a fresh one. No watched stream ever
// loops in place: `loop_region` belongs to the static path, never a streamed one
// (see `stream_from_bytes`), so a backwards jump has no other reading.
if pos < last_pos {
self.phase = Phase::Starting {
since: now,
pos,
warned: false,
};
self.expected = 0.0;
self.advanced = 0.0;
return None;
}
self.expected += dt;
self.advanced += pos - last_pos;
self.phase = Phase::Running {
last_pos: pos,
window_start,
};
if self.expected < STREAM_WATCH_WINDOW_SECS {
return None;
}
let (counted, advanced) = (self.expected, self.advanced);
let span = now.duration_since(window_start).as_secs_f64();
// The next window's origin is THIS instant — the one its first delta reaches back
// to.
self.begin(pos, now);
let lost = counted - advanced;
(lost > STREAM_STARVED_MIN_SECS).then_some(Verdict::Starved {
lost,
counted,
advanced,
span,
})
}
}
let lost = self.expected - self.advanced;
self.closed = (self.expected, self.advanced);
self.expected = 0.0;
self.advanced = 0.0;
(lost > STREAM_STARVED_MIN_SECS).then_some(lost)
}
}
@@ -1367,20 +1508,32 @@ mod tests {
);
}
/// The starvation accounting (decision 1109): a stream that advances in lockstep with wall
/// time stays quiet; one whose position freezes mid-window (kira's zero-fill) reports the
/// missing time; a slot swap's one-frame backward jump never reads as a freeze; and going
/// Frame `i`'s wall instant, so a test reads as a timeline rather than as bookkeeping.
fn wall(t0: std::time::Instant, secs: f64) -> std::time::Instant {
t0 + std::time::Duration::from_secs_f64(secs)
}
/// The starvation accounting (decisions 1109, 2253): a stream that advances in lockstep with
/// wall time stays quiet; one whose position freezes mid-window (kira's zero-fill) reports the
/// missing time; a slot swap's backward jump is a new stream, not a freeze; and going
/// non-audible drops the baseline so a later stream starts clean.
#[test]
fn stream_watch_accounts_freezes_not_swaps() {
let dt = 1.0 / 60.0;
let t0 = std::time::Instant::now();
// Healthy: position tracks wall time exactly — a full window closes clean.
// Healthy: position tracks wall time exactly — every window closes clean.
let mut w = StreamWatch::new("test");
let mut pos = 0.0;
for _ in 0..90 {
assert_eq!(w.observe(true, pos, dt), None, "healthy stream reported");
for i in 0..90 {
pos += dt;
assert!(
!matches!(
w.observe(true, pos, dt, wall(t0, f64::from(i) * dt)),
Some(Verdict::Starved { .. })
),
"healthy stream reported"
);
}
// Starved: 18 frames (~0.3 s) frozen inside the window → that window reports ~0.3 s.
@@ -1391,26 +1544,35 @@ mod tests {
if !(30..48).contains(&i) {
pos += dt;
}
if let Some(lost) = w.observe(true, pos, dt) {
reports.push(lost);
if let Some(Verdict::Starved {
lost,
counted,
span,
..
}) = w.observe(true, pos, dt, wall(t0, f64::from(i) * dt))
{
reports.push((lost, counted, span));
}
}
assert_eq!(reports.len(), 1, "one starved window: {reports:?}");
assert!(
(reports[0] - 0.3).abs() < 0.05,
(reports[0].0 - 0.3).abs() < 0.05,
"lost ≈ 0.3 s, got {reports:?}"
);
// Slot swap: position jumps backwards once (new track starts at 0) — no report.
// Slot swap: the position jumps backwards once (the new track starts at 0). That is a new
// stream, not a freeze — it re-enters the spin-up, and nobody is charged for the jump.
let mut w = StreamWatch::new("test");
let mut pos = 40.0;
for i in 0..90 {
for i in 0..120 {
if i == 30 {
pos = 0.0;
}
assert_eq!(
w.observe(true, pos, dt),
None,
assert!(
!matches!(
w.observe(true, pos, dt, wall(t0, f64::from(i) * dt)),
Some(Verdict::Starved { .. })
),
"slot swap reported as freeze"
);
pos += dt;
@@ -1420,18 +1582,168 @@ mod tests {
// the old one's) opens a fresh window instead of inheriting a phantom freeze.
let mut w = StreamWatch::new("test");
let mut pos = 70.0;
for _ in 0..30 {
assert_eq!(w.observe(true, pos, dt), None);
for i in 0..30 {
assert!(w
.observe(true, pos, dt, wall(t0, f64::from(i) * dt))
.is_none_or(|v| matches!(v, Verdict::Began { .. })));
pos += dt;
}
assert_eq!(w.observe(false, pos, dt), None);
assert!(w.observe(false, pos, dt, wall(t0, 0.5)).is_none());
let mut pos = 0.0;
for _ in 0..90 {
assert_eq!(w.observe(true, pos, dt), None, "restart read as freeze");
for i in 30..120 {
assert!(
!matches!(
w.observe(true, pos, dt, wall(t0, f64::from(i) * dt)),
Some(Verdict::Starved { .. })
),
"restart read as freeze"
);
pos += dt;
}
}
/// **The span is the interval that was counted** — the pin on decision 2253's off-by-one.
///
/// The window used to be wall-stamped lazily, on the first frame that found nothing counted
/// yet — one frame *after* the frame its first delta reaches back to. So a hitch at the head
/// of a window was counted but not spanned, and the line contradicted itself: the 2026-09-15
/// login printed `counted 1.00 s` over a `0.70 s` window with a 298 ms hitch in the gap, and
/// two earlier logins had called the same arithmetic impossible and suspected the clock.
/// Here the head of the window *is* that hitch, and the stream froze through it.
#[test]
fn stream_watch_spans_the_window_it_counted() {
let dt = 1.0 / 60.0;
let t0 = std::time::Instant::now();
let hitch = 0.298;
let mut w = StreamWatch::new("test");
// `play()` has returned; nothing has been heard yet, so nothing is counted yet.
assert!(w.observe(true, 0.0, dt, wall(t0, 0.0)).is_none());
// The first sample lands: the window opens here, at this instant.
assert!(matches!(
w.observe(true, 0.001, dt, wall(t0, 0.010)),
Some(Verdict::Began { .. })
));
// The hitch — 298 ms of wall in one frame, the stream frozen through it. Its delta
// reaches back to the instant the window opened, so the span must contain it.
assert!(w
.observe(true, 0.001, hitch, wall(t0, 0.010 + hitch))
.is_none());
let mut pos = 0.001;
let mut report = None;
for i in 0..90 {
pos += dt;
let t = 0.010 + hitch + f64::from(i + 1) * dt;
if let Some(v) = w.observe(true, pos, dt, wall(t0, t)) {
report = Some(v);
break;
}
}
let Some(Verdict::Starved {
lost,
counted,
span,
..
}) = report
else {
panic!("the frozen hitch went unreported");
};
assert!(
(counted - span).abs() < 1e-6,
"the window must span the interval it counted: counted {counted}, span {span}"
);
assert!(
(lost - hitch).abs() < 0.01,
"the hitch is the whole loss: {lost}"
);
}
/// **A stream's spin-up is not a dropout** (decision 2253), driven here on the timeline of
/// the 2026-09-15 login that reported the glue theme's own first 235 ms as injected silence.
///
/// kira publishes `Playing` with `position = 0.0` the moment `play()` returns, and
/// `StreamingSound::process` zero-fills *without advancing the position* until the decode
/// thread has two frames ready. So every stream opens frozen, and that interval is latency
/// before the first sample — never silence cut into audio that was already playing.
///
/// That login's shape, relative to the frame the theme started on: the output device opened
/// and the stream was played inside a frame that then blocked for 298 ms, and the first
/// sample landed 233 ms in (~100 ms of mix-ahead, ~11 ms of IO buffer, the rest decode).
/// The old accounting charged all of it, closed its first window at 1.00 s counted against
/// 0.77 s advanced, and called the difference a crackle.
#[test]
fn stream_watch_does_not_charge_a_stream_for_starting() {
let dt = 1.0 / 60.0;
let t0 = std::time::Instant::now();
let hitch = 0.298;
let first_sample = 0.233;
// Position as the handle reports it: pinned where it was constructed until the renderer
// has audio to consume, then advancing with wall time.
let pos_at = |t: f64| (t - first_sample).max(0.0);
let mut w = StreamWatch::new("test");
let mut began = Vec::new();
let mut starved = 0;
// The frame the theme started on — audible, position still 0 — and then the hitch.
let mut ticks = vec![0.0, hitch];
// Then three seconds of ordinary frames.
for i in 1..=180 {
ticks.push(hitch + f64::from(i) * dt);
}
for (i, t) in ticks.iter().enumerate() {
let delta = if i == 0 { dt } else { t - ticks[i - 1] };
match w.observe(true, pos_at(*t), delta, wall(t0, *t)) {
Some(Verdict::Began { after }) => began.push(after),
Some(Verdict::Starved {
lost,
counted,
span,
..
}) => {
starved += 1;
eprintln!("starved: lost {lost} counted {counted} span {span}");
}
Some(Verdict::NeverStarted { .. }) => {
panic!("a playing stream read as never started")
}
None => {}
}
}
assert_eq!(
starved, 0,
"the login's own spin-up was charged as injected silence again"
);
assert_eq!(began.len(), 1, "one spin-up line per stream: {began:?}");
// Frame-quantized: the first advance is only visible on the frame after the hitch.
assert!(
(began[0] - hitch).abs() < 1e-6,
"the skipped spin-up, stated as the bound it is: {began:?}"
);
}
/// Excluding the spin-up must not mean excluding a spin-up that never ends: a stream that
/// goes audible and never plays a sample is still named, once (decision 2253).
#[test]
fn stream_watch_names_a_stream_that_never_starts() {
let dt = 1.0 / 60.0;
let t0 = std::time::Instant::now();
let mut w = StreamWatch::new("test");
let mut waited = Vec::new();
for i in 0..600 {
if let Some(Verdict::NeverStarted { waited: s }) =
w.observe(true, 0.0, dt, wall(t0, f64::from(i) * dt))
{
waited.push(s);
}
}
assert_eq!(waited.len(), 1, "one line per stream, not one per frame");
assert!(
waited[0] > STREAM_START_MAX_SECS && waited[0] < STREAM_START_MAX_SECS + 0.1,
"named as soon as the wait is past the bound: {waited:?}"
);
}
/// The fade contract every transition rests on: `stop(tween)` fades the channel to silence
/// over the tween's duration on the audio thread, and **dropping the handle immediately after
/// does not cut it short** — the command is already published to the renderer's triple-buffer
+32 -6
View File
@@ -564,6 +564,9 @@ pub(super) struct GoPickSet<'w, 's> {
/// nothing ever reads.
frame: Local<'s, u64>,
report: Local<'s, super::hover_probe::ProbeReport>,
/// `WOW_HOVER_PROBE=lock`'s held target (2255) — the object the probe rests its aim on for the
/// remainder of the run.
lock: Local<'s, super::hover_probe::LockedAim>,
}
/// The number of `ChildOf` hops [`net_entity_of`] will climb before giving up — a malformed-data
@@ -677,20 +680,32 @@ pub(super) fn update_hovered_object(
hovered.target = None;
hovered.guid = None;
hovered.distance = f32::MAX;
if rig.is_looking() || pointer_over_ui.0 {
// The sticky-hover cache drops the moment the pointer is not ours, exactly as before — the
// probe's aim below must not change *when* that happens.
let yielded = rig.is_looking() || pointer_over_ui.0;
if yielded {
*last_pick = None;
return;
}
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
// The aim. A person's pointer always wins; the probe (2250) answers only for a window that has
// no cursor at all, which is every automated run — see [`super::hover_probe`].
//
// **Resolved and PUBLISHED ahead of the yield below** (2255), because the UI mouse feed reads
// the published aim as its pointer: a frame that returned early without publishing would take
// the pointer off the UI, drop `PointerOverUi`, and unlatch the very gate that skipped the
// pick — the probe would flicker the interface on and off instead of resting on it.
let probing = super::hover_probe::armed();
let Some(cursor) = window
.cursor_position()
.or_else(|| super::hover_probe::point(window, *cache.frame))
else {
let probe_aim = cache
.lock
.point(camera, cam_tf, |e| parts.get(e).ok().map(|(_, gt, ..)| *gt))
.or_else(|| super::hover_probe::point(window, *cache.frame));
super::hover_probe::publish(probe_aim);
if yielded {
return;
}
let Some(cursor) = window.cursor_position().or(probe_aim) else {
return;
};
if pickable.is_empty() {
@@ -861,6 +876,17 @@ pub(super) fn update_hovered_object(
tmpl.map(|t| t.highlight_column),
occlusion.distance,
));
// `lock` takes its target here and nowhere else: past the eligibility gate, so the
// probe holds something that actually publishes a mouseover rather than the first
// lamp-post the grid grazed. The hit point is re-expressed in the PART's frame so the
// aim survives the camera settling and the object moving (see [`LockedAim`]).
if super::hover_probe::locks() {
if let Some((t, part)) = best {
if let Ok((_, gt, ..)) = parts.get(part) {
cache.lock.hold(part, gt, ray.origin + *ray.direction * t);
}
}
}
}
}
hovered.target = Some(net_entity);
+140 -4
View File
@@ -20,12 +20,23 @@
//! | `centre` / `center` | the window's middle |
//! | `<x>,<y>` | that point |
//! | `sweep` | a 7×5 grid across the middle half of the window, one point per frame, cycling |
//! | `lock` | sweep until something is picked, then HOLD that object for the rest of the run |
//!
//! `sweep` is the one that makes a rig run useful without a camera: standing a body in front of a
//! known object and asking *"what is anywhere near the middle of the screen"* answers the question
//! a fixed point can only answer if the aim was already right. It is a probe, not a camera search —
//! the grid is fixed, bounded and the same every run.
//!
//! **`lock` is what a resting pointer looks like, and `sweep` cannot stand in for it** (2255). A
//! sweep re-enters the object every cycle, and a *re-enter* is precisely what rebuilds the
//! mouseover plate — so a whole class of defect, the one where a plate that is already up never
//! comes back after something else takes the tooltip, is invisible to a sweep by construction. It
//! is also what a fixed `x,y` cannot do: the camera settles over the first seconds of a rig run and
//! the object slides across the screen, so a hand-aimed point that hit at 30 s misses at 60 s.
//! `lock` holds the hit POINT IN THE OBJECT'S OWN FRAME and re-projects it every frame, so the aim
//! follows the object through camera drift and through the object's own motion. It never releases:
//! that is the point — it stands for a pointer a person put down and left there.
//!
//! **Armed only when the window reports no cursor**, so an attended run is never overridden: a
//! person's pointer always wins, and leaving the variable set costs a player nothing.
@@ -38,6 +49,7 @@ enum Aim {
Centre,
At(i32, i32),
Sweep,
Lock,
}
fn aim() -> Option<Aim> {
@@ -48,16 +60,23 @@ fn aim() -> Option<Aim> {
match v {
"centre" | "center" => Some(Aim::Centre),
"sweep" => Some(Aim::Sweep),
"lock" => Some(Aim::Lock),
_ => match v.split_once(',') {
Some((x, y)) => match (x.trim().parse(), y.trim().parse()) {
(Ok(x), Ok(y)) => Some(Aim::At(x, y)),
_ => {
warn!("hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep` or `x,y`");
warn!(
"hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep`, \
`lock` or `x,y`"
);
None
}
},
None => {
warn!("hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep` or `x,y`");
warn!(
"hover probe: WOW_HOVER_PROBE={v:?} is not `centre`, `sweep`, `lock` or \
`x,y`"
);
None
}
},
@@ -79,8 +98,9 @@ pub(super) fn point(window: &Window, frame: u64) -> Option<Vec2> {
Aim::At(x, y) => Some(Vec2::new(x as f32, y as f32)),
// The grid: 7 columns × 5 rows over the middle half, so the edges of the frame (chrome,
// sky, the player's own back) are never the answer. One cell per frame, cycling — at frame
// rate the whole grid is covered ~2× a second.
Aim::Sweep => {
// rate the whole grid is covered ~2× a second. `lock` searches with the same grid; once it
// holds an object [`LockedAim::point`] answers ahead of this and the grid stops mattering.
Aim::Sweep | Aim::Lock => {
const COLS: u64 = 7;
const ROWS: u64 = 5;
let cell = frame % (COLS * ROWS);
@@ -92,6 +112,79 @@ pub(super) fn point(window: &Window, frame: u64) -> Option<Vec2> {
}
}
/// Does the aim HOLD its first pick? (`WOW_HOVER_PROBE=lock`.)
pub(super) fn locks() -> bool {
matches!(aim(), Some(Aim::Lock))
}
/// **This frame's aim**, published by the pick and read by everything else that needs to know
/// where the probe is pointing: the UI mouse feed (so `PointerOverUi` rises and falls over a panel
/// exactly as it does for a person) and the tooltip's cursor-seated arm.
///
/// It is a process-global rather than a resource for two reasons. The probe already is one — the
/// env var behind [`aim`] — and its three readers sit in three different systems that are each at
/// or near Bevy's parameter ceiling, so a resource would cost a bundle refactor apiece for a debug
/// instrument with exactly one writer.
///
/// **One aim, published once per frame, is the correctness claim and not a convenience.** Before
/// 2255 each reader called [`point`] again for itself, and the tooltip's call passed frame `0` —
/// so on a `sweep` the plate was seated at grid cell 0 while the pick was aiming at cell N, and the
/// two disagreed about where the pointer was on 34 frames out of every 35.
static AIM_NOW: std::sync::Mutex<Option<Vec2>> = std::sync::Mutex::new(None);
/// Publish this frame's aim. Called by the pick BEFORE its own gates: a frame that skipped the
/// pick must still report a pointer, or the UI feed would take the pointer off the UI and unlatch
/// the very gate that skipped it.
pub(super) fn publish(point: Option<Vec2>) {
*AIM_NOW.lock().unwrap_or_else(|e| e.into_inner()) = point;
}
/// This frame's aim, as published. `None` when the probe is not armed.
pub(super) fn now() -> Option<Vec2> {
*AIM_NOW.lock().unwrap_or_else(|e| e.into_inner())
}
/// `lock` mode's held target: the part the probe first picked, and the point it hit **in that
/// part's own frame**. Re-projected every frame, so the aim follows the object rather than going
/// stale the moment the camera settles or the object moves.
#[derive(Default)]
pub(super) struct LockedAim {
held: Option<(Entity, Vec3)>,
}
impl LockedAim {
/// Where the held point is on screen now, or `None` while nothing is held (the grid still
/// searches) or while it has gone off-camera (the grid searches again).
pub(super) fn point(
&self,
camera: &Camera,
cam_tf: &GlobalTransform,
transform_of: impl Fn(Entity) -> Option<GlobalTransform>,
) -> Option<Vec2> {
let (part, local) = self.held?;
let gt = transform_of(part)?;
camera
.world_to_viewport(cam_tf, gt.transform_point(local))
.ok()
}
/// Hold this hit — **once**. A lock that never releases is the whole point; re-locking onto
/// whatever happened to be picked later would make the aim wander exactly like the sweep it
/// exists to replace.
///
/// Deliberately does **not** ask [`locks`] itself: the caller gates on the mode (a `sweep` that
/// held its first hit would stop sweeping), and keeping this half pure is what lets a test
/// drive it without an env var behind a `OnceLock`.
pub(super) fn hold(&mut self, part: Entity, gt: &GlobalTransform, hit_world: Vec3) {
if self.held.is_some() {
return;
}
let local = gt.affine().inverse().transform_point3(hit_world);
info!("hover probe: LOCKED on {part} at its own {local:?} — the aim holds here now");
self.held = Some((part, local));
}
}
/// One line per *change*, so a stationary probe does not flood the log: what the pick found at the
/// aim point, and every term of the GameObject tooltip ladder the card shows a person (2248) —
/// eligibility, the published mouseover, and the ask-once template the plate needs.
@@ -130,6 +223,49 @@ mod tests {
}
}
/// **The lock holds its FIRST target and keeps it**, and holds it in the object's own frame —
/// the two properties that make a locked aim stand for a resting pointer rather than a slowly
/// wandering one.
#[test]
fn the_lock_holds_its_first_target_in_the_objects_own_frame() {
let mut l = LockedAim::default();
let first = Entity::from_raw_u32(1).expect("a valid test entity id");
let later = Entity::from_raw_u32(2).expect("a valid test entity id");
// A part standing 10 yd east; the ray landed 2 yd up its face.
let gt = GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0));
l.hold(first, &gt, Vec3::new(10.0, 2.0, 0.0));
assert_eq!(
l.held,
Some((first, Vec3::new(0.0, 2.0, 0.0))),
"the hit is stored relative to the part, not in world space"
);
l.hold(later, &gt, Vec3::ZERO);
assert_eq!(
l.held.map(|(e, _)| e),
Some(first),
"a later pick never steals the lock"
);
}
/// Nothing held is nothing aimed — the search half keeps running until the first hit.
#[test]
fn an_unheld_lock_aims_at_nothing() {
let l = LockedAim::default();
let cam = Camera::default();
assert!(l
.point(&cam, &GlobalTransform::IDENTITY, |_| None)
.is_none());
}
/// The published aim round-trips, and clearing it is how a frame says "no pointer here".
#[test]
fn the_published_aim_round_trips() {
publish(Some(Vec2::new(3.0, 4.0)));
assert_eq!(now(), Some(Vec2::new(3.0, 4.0)));
publish(None);
assert_eq!(now(), None);
}
/// A repeated line is said once — the property that makes the probe readable in a log rather
/// than 60 identical lines a second.
#[test]
+5 -3
View File
@@ -73,9 +73,11 @@ pub(crate) mod hover;
/// The headless hover probe (2250) — see its header.
mod hover_probe;
/// The probe's aim for a window with no OS cursor, for the tooltip's cursor-seated arm (2250).
pub(crate) fn hover_probe_point(window: &bevy::window::Window) -> Option<bevy::math::Vec2> {
hover_probe::point(window, 0)
/// The probe's aim for a window with no OS cursor — for the tooltip's cursor-seated arm and the
/// UI mouse feed (2250; 2255 made it THIS frame's aim as the pick published it, rather than a
/// second, independently recomputed one that disagreed with the pick on 34 sweep frames in 35).
pub(crate) fn hover_probe_point() -> Option<bevy::math::Vec2> {
hover_probe::now()
}
/// Is the headless hover probe armed? (Gates its own log lines outside this module.)
+30 -26
View File
@@ -612,34 +612,38 @@ fn feed_quest_log(
.filter(|t| !t.is_empty())
};
// ── The IN-FLIGHT GATE — all of the log, or none of it ──────────────────────────────────────
// The reference's rebuild (`0x4de510`) counts cache misses into `[0xbb7498]`, skips the row
// for each miss entirely (`0x4de66b`/`0x4de67e` — no row AND no header for its group), and
// then `0x4de545` makes any FURTHER rebuild a total no-op — not even `QUEST_LOG_UPDATE` —
// while the counter is non-zero. Only the arriving response decrements it, rebuilding once
// when it reaches 0. So a cold cache shows an EMPTY log (`GetNumQuestLogEntries()` → `0, 0`,
// and the stock UI paints `EmptyQuestLogFrame`), and then the whole thing appears in ONE step,
// complete. **It is never partially filled** (wow-re `ui/scratch/questlog-list-rebuild.md`,
// §5 trio + arbitration; our B3 claim — a placeholder row per in-flight quest — was REFUTED).
// ── The IN-FLIGHT SKIP — the cached rows, and only those ────────────────────────────────────
// The reference's rebuild (`0x4de510`) counts cache misses into `[0xbb7498]` and skips the
// MISSING QUEST — not the pass. The miss at `0x4de67e` is a `jmp 0x4de729`, which is the slot
// loop's own increment block, one instruction above the back-edge: every later slot is still
// visited and every later cache hit still appends its row at `0x4de68e`. The hit path's own
// "this group already has a header" branch (`0x4de6f1`) jumps to the same address right after
// appending a row, which a discard could not do. The tail past the loop is unconditional —
// both sorts, the collapse restore, the watch prune, and `QUEST_LOG_UPDATE` (`0x4de811`).
// `0x4de545` then makes any FURTHER rebuild a no-op until the last outstanding response
// decrements the counter to 0 and `0x4de8b0` rebuilds once more.
//
// The empty window is the COLD case only: the reference loads `WDB/questcache.wdb` into this
// same cache before world-enter (`0x554f9b`), so a returning character's rows are there
// immediately. We have no such disk cache yet, so ours is the cold path every login — which is
// exactly the reference's own behaviour on a fresh `WDB`, not a deviation.
// So `GetNumQuestLogEntries()` answers `(visible rows, CACHED quest count)`, and `0, 0` is the
// all-cold extreme rather than the general law. Skipping the whole log on any miss — what this
// did, on a note that read the cold case as the rule — blanked the entire window for a round
// trip every time a quest whose template we had never seen entered the log. That took the
// engine selection with it (`remap_selection` reads an empty list as "your quest is gone"), so
// the detail pane jumped to row 1 on every single pickup, and addons reading the log across
// the blank saw a log that briefly held nothing. Settled at the bytes by wow-re, whose
// `scratch/questlog-list-rebuild.md` §1.1 now carries the partial case (decision 2256).
//
// `template()` is still called for every row first, because the miss is what SENDS the query
// (the reference's rebuild sends `CMSG_QUEST_QUERY` too, once per id ever — our `pending` set
// is that same dedupe).
// Deliberately a loop and NOT `.all(..)`: `template()` is what SENDS the query, and the
// reference asks for every missing id, not just the first. Short-circuiting here would leave
// the rest of a cold log unrequested — and then nothing would ever arrive to un-gate it.
let mut all_cached = true;
for r in &rows {
if quest_log.template(r.quest_id, &commands).is_none() {
all_cached = false;
}
}
let rows: Vec<Row> = if all_cached { rows } else { Vec::new() };
// `template()` is called for EVERY row before any filtering, because the miss is what SENDS
// the query (our `pending` set is the reference's once-per-id dedupe) — short-circuiting would
// leave the rest of a cold log unrequested, and then nothing would arrive to fill it in.
let cached: Vec<bool> = rows
.iter()
.map(|r| quest_log.template(r.quest_id, &commands).is_some())
.collect();
let rows: Vec<Row> = rows
.into_iter()
.zip(cached)
.filter_map(|(r, hit)| hit.then_some(r))
.collect();
// ── Section headers (the ref's zone/sort groups) ────────────────────────────────────────────
// One header per distinct `ZoneOrSort` (positive → AreaTable zone, negative → QuestSort sort —
@@ -139,8 +139,14 @@ pub(super) fn feed_ui_input(
// nobody can see must not eat the click or arm a tooltip, and the else-arm below is exactly the
// "no pointer here" bookkeeping (leave the hovered frame once, disarm any press/drag every
// frame) that keeps a stale gesture from firing when the UI comes back.
// The headless hover probe's aim stands in for a cursor the window does not have (2250/2255),
// so `PointerOverUi` rises over a panel and falls off it in an automated run exactly as it does
// for a person — which is what lets a rig run reproduce "open the map, close it, and the world
// under it goes quiet". A person's pointer always wins; an unarmed probe answers `None` and
// nothing here changes.
if let Some(cursor) = window
.cursor_position()
.or_else(crate::target::hover_probe_point)
.filter(|_| !ui_hidden && !synthetic)
{
// Window cursor is logical px, y-down from top-left; the UI is y-up 768-virtual units
+6
View File
@@ -1019,6 +1019,12 @@ mod shape_gate;
#[cfg(test)]
mod event_shape_gate;
/// The VERB-FIRED event gate (decision 2251) — the third question on the same seam: 1883/1889
/// compare names and 2140 compares arguments; this asks WHO fires it, because a stock file that
/// calls a verb for its side effect of an event repaints nothing when the verb fires nothing.
#[cfg(test)]
mod verb_event_gate;
/// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog,
/// none of which benilla itself calls: every test enters from Lua the way an addon does.
#[cfg(test)]
@@ -2357,12 +2357,6 @@ mod tests {
flow works without it",
),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"LEARNED_SPELL_IN_TAB",
"SpellBookFrame.lua — the tab flash when a new spell lands in it; the reference \
fires it with the tab index on spell add, which benilla's spellbook feed does not \
derive yet (1952)",
),
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
@@ -515,3 +515,53 @@ fn buff_hover_hangs_below_left_of_the_button() {
"buff tooltip hangs its TOPRIGHT on the button's BOTTOMLEFT"
);
}
/// **The cursor-seated GameObject plate carries an OWNER** — the store the reference's publisher
/// makes through the SetOwner core (`0x492a01 → 0x52ffe0(owner, 6, 0, 0)`, whose `0x53000c`
/// writes `+0x314`), and the one arm of ours that used to skip it (decision 2255).
///
/// Every other world plate reached an owner by accident, through Lua: the corner arm and the unit
/// flow both fire `OnTooltipSetDefaultAnchor`, and the stock handler calls
/// `GameTooltip:SetOwner(UIParent, …)`. The cursor arm fires nothing, so `IsOwned` answered false
/// for exactly the GENERIC(5) objects — a signpost, a mailbox — and the next test is what that
/// cost.
#[test]
fn a_cursor_seated_gameobject_plate_is_owned() {
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
assert!(
owned,
"the signpost plate is owned; errors: {:?}",
s.errors()
);
}
/// **An addon's `OnShow` hook must not hide the plate the world hover just built** — the
/// director's signpost with no tooltip (decision 2255).
///
/// `!Questie` installs an `OnShow` on GameTooltip at PLAYER_LOGIN (`Questie:hookTooltip` — it
/// installs one precisely *because* the stock plate has none) whose handler ends in
/// `GameTooltip:Show()`. Lua's `:Show()` is the reference's EXISTENCE GATE `0x530a80`: owner and
/// line count both non-zero, or it takes the effective-hide `0x530a60` instead. So an unowned
/// plate hides itself the instant it is shown — through our own faithful implementation of that
/// gate, ~26 ms after the engine built it, on every signpost, for the whole session.
///
/// The reference cannot reach that state, because its publisher writes the owner *before* the
/// plate is ever shown. With the owner written, so do we.
#[test]
fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
let mut s = harness(&[]);
// Questie's hook, in one line: the plate's own show event calls Show() again.
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
.unwrap();
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(
shown,
"the signpost plate is still up; errors: {:?}",
s.errors()
);
}
@@ -0,0 +1,418 @@
//! **The VERB-FIRED event gate** — `reference/1.12-verb-events.tsv` against the module that
//! registers each verb (decision 2251).
//!
//! ## The third question on the event seam
//!
//! Two gates compare event NAMES (`reference_ui`: a stock listener nothing feeds, 1889; a fire the
//! reference has no name for, 1883) and one compares ARGUMENTS (`event_shape_gate`, 2140). None of
//! them asks **who** fires it, and that is the hole B389/2244 fell through. The reference's
//! `SetTrainerServiceTypeFilter` commits through `0x4d8c90`, whose whole body is *write the mask,
//! re-run the finalizer, fire `TRAINER_UPDATE`*; so the stock `Blizzard_TrainerUI.lua` never
//! repaints the list itself — it calls the verb for its side effect. benilla's verb set the mask
//! and fired nothing. The name gate stayed green because the packet arm fires the same event, and
//! the tests stayed green because they asserted the mask. Our own retired `TrainerFrame.xml` had
//! repainted explicitly, so the behaviour left with the file that was compensating for it (1957),
//! and the director found it twelve days later. Every window that went stock (1751) carries the
//! same exposure: a verb the stock Lua calls *for the event*.
//!
//! ## The rule
//!
//! For every pair the reference fires on the verb's own call path (the table: the verb's body, or
//! a helper that only registered verbs call), and every verb benilla registers: **a file that
//! registers the verb also fires the event** — or the pair is declared below, and the declaration
//! is *checked*, never trusted:
//!
//! * [`ELSEWHERE`] — benilla fires it from a named feed instead, on the state the verb changed
//! (the verb bumps a generation, sets a touched flag, queues an intent the app drains). That
//! file must fire it, and the verb's own file must not, or the row is stale.
//! * [`GAP`] — nothing fires it on this verb's path, for a stated reason. The verb's own file
//! must still not fire it, or the row is stale.
//!
//! A verb no file registers is skipped: unbuilt verbs are `scripts/api-coverage.sh`'s queue
//! (1178), not this gate's.
//!
//! ## What "registers" and "fires" mean here, and the limit
//!
//! Text, not execution: the quoted literal `"Verb"` or `"EVENT"` in a non-test source file, with
//! comment lines dropped and the trailing test module cut. Lenient in exactly one direction — any
//! file that names the verb and fires the event passes — because the honest alternative, proving
//! the path *executes*, is a per-verb fixture, and 2244's own test is the measure of a fixture that
//! asserts the wrong thing. What this cannot see: a fire that is in the file but off the verb's
//! path — an `ELSEWHERE` row is a claim the session that wrote it read at the feed. What it sees
//! every time is the migration class itself: a verb whose module fires nothing.
use std::collections::BTreeSet;
use std::path::PathBuf;
/// One row of `reference/1.12-verb-events.tsv`.
struct Pair {
verb: String,
event: String,
shape: String,
via: String,
}
/// Pairs benilla fires from a feed, on the state the verb changes. `(verb, event, the file that
/// fires it)`; the file is matched as a path suffix. Each was read at the feed when the row was
/// written — the trigger is named so the next reader can check it in one grep.
const ELSEWHERE: &[(&str, &str, &str)] = &[
// The sell slot's diff (`auction_sell_item` moved, or `sell_slot_dirty`).
(
"CalculateAuctionDeposit",
"NEW_AUCTION_UPDATE",
"benilla-app/src/ui_auction/mod.rs",
),
(
"ClickAuctionSellItemButton",
"NEW_AUCTION_UPDATE",
"benilla-app/src/ui_auction/mod.rs",
),
// The query goes to the wire; the inbox feed announces the list when it lands.
(
"CheckInbox",
"MAIL_INBOX_UPDATE",
"benilla-app/src/ui_mail/mod.rs",
),
// `craft_close` / `trade_skill_close` are drained by the app, whose store transition
// `(Some, None)` is the fire.
("CloseCraft", "CRAFT_CLOSE", "benilla-app/src/ui_craft.rs"),
(
"CloseTradeSkill",
"TRADE_SKILL_CLOSE",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_quest_log_collapses`: the app owns the collapse set, re-feeds the list, fires.
(
"CollapseQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
(
"ExpandQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
// `trade_skill_touched`, set by all four verbs, drained by `take_trade_skill_touched`.
(
"CollapseTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"ExpandTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillInvSlotFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillSubClassFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
// `repeat_count` moved (`repeat_changed`).
(
"DoTradeSkill",
"UPDATE_TRADESKILL_RECAST",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_loot_roll_confirms`: a Need/Greed on a bind-on-pickup roll is diverted to the popup.
(
"ConfirmLootRoll",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"ConfirmLootRoll",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
// `keybinds.generation` moves; `sync_dispatch` re-derives the table and fires.
(
"SetBinding",
"UPDATE_BINDINGS",
"benilla-app/src/bindings.rs",
),
// A `SessionRequest::StopCinematic`; the player stops, and the flip to not-playing fires.
(
"StopCinematic",
"CINEMATIC_STOP",
"benilla-app/src/cinematic.rs",
),
// The toggle goes to the wire and the bar's pushed key changes on the reply — one round trip
// later than the reference, which fires locally from the toggle.
(
"TogglePetAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
(
"ToggleSpellAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
];
/// Pairs nothing fires on the verb's path, each with its reason. A row is a gap with a name,
/// never a reason to stop; the reference's own site is in the table for the grep into wow-re.
const GAP: &[(&str, &str, &str)] = &[
(
"CancelSkillUps",
"SKILL_LINES_CHANGED",
"resets temp skill points over a table this model keeps empty (`skills.rs`), so nothing \
moves and `ui_char.rs`'s feed has nothing to announce; the reference fires from \
0x4d3e3a regardless",
),
(
"ClickTargetTradeButton",
"TRADE_REPLACE_ENCHANT",
"the trade-window enchant leg (0592 P3) is not built; the verb is the money arm only",
),
(
"CloseTrade",
"PLAYER_TRADE_MONEY",
"`ui_trade.rs` announces money only while the trade is open; the reference's close zeroes \
the offer and fires from 0x4bfdce, under a frame that TRADE_CLOSED hides first",
),
(
"CollapseCraftSkillLine",
"CRAFT_UPDATE",
"a no-op here: the craft list is flat, the header law was never ported from TradeSkill \
(0446, 0530's follow-up), so there is nothing to repaint",
),
(
"ExpandCraftSkillLine",
"CRAFT_UPDATE",
"the same no-op as CollapseCraftSkillLine",
),
(
"PetDismiss",
"PET_DISMISS_START",
"no shipped file listens; the dismiss reaches the wire through the app's drain and the \
pet frame follows the unit's departure",
),
(
"SelectGossipOption",
"GOSSIP_ENTER_CODE",
"the code-entry gossip option kind is not modelled (`reference_ui`'s UNPRODUCED row)",
),
(
"SetPetStablePaperdoll",
"PET_STABLE_UPDATE_PAPERDOLL",
"inert by design: the stable's model pane is an app-side booth that follows the selection \
every frame (1676), so the VM has nothing to point",
),
(
"UpdateMapHighlight",
"CLOSE_WORLD_MAP",
"0x4a7620 fires it (site 0x4a7835, id 373) on a path the highlight verb reaches under a \
condition nobody has read; unbuilt until carved",
),
];
fn table() -> Vec<Pair> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-verb-events.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-verb-events.tsv");
text.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("verb\t"))
.filter_map(|l| {
let f: Vec<&str> = l.split('\t').collect();
(f.len() >= 4).then(|| Pair {
verb: f[0].to_string(),
event: f[1].to_string(),
shape: f[2].to_string(),
via: f[3].to_string(),
})
})
.collect()
}
/// Every non-test `.rs` under `crates/`: comment lines dropped, the trailing test module cut.
fn sources() -> Vec<(PathBuf, String)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
// The same file-name rule the sibling gates use: a test's own literals are not a
// surface we ship — and neither are the gates' own declared lists, this file's
// included (a `*_gate.rs` is a `#[cfg(test)]` module whose name does not say so).
let name = p.to_string_lossy();
if p.extension().is_none_or(|x| x != "rs")
|| name.contains("test")
|| name.ends_with("_gate.rs")
{
continue;
}
let whole = std::fs::read_to_string(&p).unwrap_or_default();
let head = whole
.rfind("#[cfg(test)]\nmod ")
.map_or(whole.as_str(), |i| &whole[..i]);
let text: String = head
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
out.push((p, text));
}
}
out.sort();
out
}
fn quoted(text: &str, name: &str) -> bool {
text.contains(&format!("\"{name}\""))
}
fn short(p: &std::path::Path) -> String {
let s = p.to_string_lossy();
s.split_once("/crates/")
.map_or(s.to_string(), |(_, t)| t.to_string())
}
/// The files that register `verb`, and whether any of them fires `event`.
fn verb_module_fires(src: &[(PathBuf, String)], verb: &str, event: &str) -> (Vec<String>, bool) {
let registrars: Vec<&(PathBuf, String)> = src.iter().filter(|(_, t)| quoted(t, verb)).collect();
let fires = registrars.iter().any(|(_, t)| quoted(t, event));
(registrars.iter().map(|(p, _)| short(p)).collect(), fires)
}
/// **Every event the reference fires from inside a verb is fired by the module that registers
/// the verb** — or declared, with the declaration checked.
#[test]
fn every_event_the_reference_fires_from_a_verb_is_fired_by_the_verb_s_module() {
let src = sources();
assert!(src.len() > 100, "source walk found {} files", src.len());
let declared: BTreeSet<(&str, &str)> = ELSEWHERE
.iter()
.chain(GAP.iter())
.map(|(v, e, _)| (*v, *e))
.collect();
let mut surprises = Vec::new();
let mut seen = 0;
for pair in table() {
let (registrars, fires) = verb_module_fires(&src, &pair.verb, &pair.event);
if registrars.is_empty() {
continue; // an unbuilt verb: api-coverage.sh's queue, not this gate's
}
seen += 1;
if fires || declared.contains(&(pair.verb.as_str(), pair.event.as_str())) {
continue;
}
let fired_by: Vec<String> = src
.iter()
.filter(|(_, t)| quoted(t, &pair.event))
.map(|(p, _)| short(p))
.collect();
surprises.push(format!(
"{} -> {} [{}{}] registered in {}; {}",
pair.verb,
pair.event,
pair.shape,
if pair.via == "-" {
String::new()
} else {
format!(" via {}", pair.via)
},
registrars.join(", "),
if fired_by.is_empty() {
"fired NOWHERE in benilla".to_string()
} else {
format!("fired only by {}", fired_by.join(", "))
},
));
}
assert!(
seen > 20,
"only {seen} pairs had a registered verb — the walk or the table is off"
);
assert!(
surprises.is_empty(),
"the reference fires these events from inside the verb, and the module that registers \
the verb here fires nothing — the stock Lua that calls the verb for its side effect \
repaints nothing (2244's class). Fire it from the verb, or declare the pair: ELSEWHERE \
when a feed fires it on THIS verb's change and you read that at the feed, GAP with the \
reason otherwise:\n {}",
surprises.join("\n ")
);
}
/// **A declaration is checked, not trusted** — an `ELSEWHERE` file that stopped firing, a verb
/// module that now fires what its row says it does not, a row for a pair the table no longer
/// carries: each fails until the row is corrected.
#[test]
fn the_declared_rows_are_still_true() {
let src = sources();
let table = table();
let in_table =
|verb: &str, event: &str| table.iter().any(|p| p.verb == verb && p.event == event);
let mut wrong = Vec::new();
for (verb, event, file) in ELSEWHERE {
if !in_table(verb, event) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: not a pair in the table any more"
));
}
let named: Vec<&(PathBuf, String)> = src
.iter()
.filter(|(p, _)| p.to_string_lossy().ends_with(file))
.collect();
if named.is_empty() {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: no source file ends with {file}"
));
} else if !named.iter().any(|(_, t)| quoted(t, event)) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: {file} no longer fires it"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
for (verb, event, _) in GAP {
if !in_table(verb, event) {
wrong.push(format!(
"GAP {verb} -> {event}: not a pair in the table any more"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"GAP {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
assert!(
wrong.is_empty(),
"stale declarations:\n {}",
wrong.join("\n ")
);
}
+172 -12
View File
@@ -36,7 +36,7 @@ use std::collections::{BTreeMap, BTreeSet};
use bevy::prelude::*;
use benilla_formats::{SkillLineCatalog, SpellCatalog};
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
use benilla_ui::script::{ScriptValue, SpellBookState, SpellSlotView, SpellTabView, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
@@ -68,11 +68,30 @@ const NO_LINE: u32 = 0;
/// a `SkillLine.dbc` lookup (key 0 has no DBC row). Extensionless, as the DBC/BLP loader expects.
const GENERAL_TAB_ICON: &str = "Interface\\Icons\\Ability_Kick";
/// **Spells learned mid-session, awaiting their tab flash** — the queue behind
/// `LEARNED_SPELL_IN_TAB` (event 510; decision 2252).
///
/// Filled by the two net arms the reference reaches its announce block from
/// (`crate::net::apply::spells`' learn and rank-up), never by the `SMSG_INITIAL_SPELLS` bulk load:
/// the reference gates all of this on `0x4b25b0`'s live-mutation flag, which the login drain
/// passes clear. Drained by [`feed_spellbook`].
///
/// **It is a queue rather than a direct fire because of ordering, and the reference has the same
/// ordering for the same reason.** `0x4b2b5a` re-sorts the tab registry and fires `SPELLS_CHANGED`
/// *first*, and only then does `0x4b2b92` fire the tab event — an index read before that sort can
/// be stale. The reference threads this by caching the pre-sort index and re-searching only when
/// the tab was newly created (`0x4b2b5f cmp edi,0xffffffff`, the one case where the sort can move
/// things); benilla has no pre-sort index to cache, because the feed computes it from the tab list
/// it has just rebuilt — so the two paths collapse into one and the result is the same index.
#[derive(Resource, Default)]
pub(crate) struct LearnedInTab(pub(crate) Vec<u32>);
pub(crate) struct UiSpellbookPlugin;
impl Plugin for UiSpellbookPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_skill_lines.after(AssetSet::Open))
app.init_resource::<LearnedInTab>()
.add_systems(Startup, load_skill_lines.after(AssetSet::Open))
.add_systems(
Update,
(
@@ -140,6 +159,7 @@ fn feed_spellbook(
cooldowns: Res<crate::cooldowns::Cooldowns>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
mut learned: ResMut<LearnedInTab>,
) {
let Some(mut script) = script else {
return;
@@ -178,6 +198,10 @@ fn feed_spellbook(
("spells", spells_changed),
("skill_lines", lines_changed),
("icons", icons_added),
// A queued learn must not be able to sit behind a skipped frame: it is only ever
// pushed on a frame that also dirties `PlayerActions`, but depending on that is a
// silent coupling, and this is the file's own way of saying so.
("learned", !learned.0.is_empty()),
],
);
let gate = gate::Gate::new(
@@ -191,11 +215,15 @@ fn feed_spellbook(
|| actions_changed
|| spells_changed
|| lines_changed
|| icons_added,
|| icons_added
|| !learned.0.is_empty(),
);
if gate.skip() {
return;
}
// Drained here, before any later `return` can strand it: an id we cannot resolve is an id
// that fires nothing, exactly as the reference's own null-record bail at the registrar's head.
let learned = std::mem::take(&mut learned.0);
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands.
let Some(spells) = spells.as_deref() else {
return;
@@ -217,7 +245,7 @@ fn feed_spellbook(
// serves the page.
let ranged_icon =
store.and_then(|s| ranged_weapon_icon(s, &mut items, icons.as_deref(), &commands));
let mut fresh = build_book(
let (mut fresh, tab_lines) = build_book(
&actions.spells,
&spells.catalog,
skill_lines.as_deref().map(|s| &s.catalog),
@@ -286,10 +314,86 @@ fn feed_spellbook(
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
}
}
// …and then the tab flash, in the reference's own order — `0x4b2b5a`'s re-sort +
// `SPELLS_CHANGED` precedes `0x4b2b92`'s `LEARNED_SPELL_IN_TAB` (decision 2252). Outside the
// diff block on purpose: a re-learn of a spell already in the book changes no snapshot, and
// the reference fires on the packet, not on a diff.
fire_tab_flashes(
&mut script,
&learned,
&tab_lines,
spells,
skill_lines.as_deref().map(|s| &s.catalog),
race,
class,
);
}
/// Fire `LEARNED_SPELL_IN_TAB` for each spell that just landed in the book — the tail of the
/// reference's registrar (`0x4b2b86 inc edi; push edi; push "%d"; push 0x1fe`), whose argument is
/// the **1-based** index of the spell's tab in the sorted tab list (decision 2252).
///
/// The stock handler is `SpellBookFrame.lua:81` — `getglobal("SpellBookSkillLineTab"..arg1.."Flash")
/// :Show()` — so the index is an index into exactly the tab strip `GetNumSpellTabs`/
/// `GetSpellTabInfo` publish, which is why it is read off `tab_lines` (the list this frame's
/// `build_book` just produced) rather than derived a second way.
///
/// **The gate is `in_spellbook`, and that is not an approximation.** The reference reaches this
/// tail only past three returns — `Attributes & 0x80` (`0x4b2911`), `Attributes & 0x20`
/// (`0x4b2944`, the recipe arm returns outright) and `castUI > 0` (`0x4b29c4 jg`) — which is
/// bit-for-bit the book add-gate `SpellDisplay::in_spellbook` already models (decision 0227). A
/// spell that is not booked flashes nothing, and cannot: it has no tab.
fn fire_tab_flashes(
script: &mut UiScript,
learned: &[u32],
tab_lines: &[u32],
spells: &Spells,
skill_lines: Option<&SkillLineCatalog>,
race: u8,
class: u8,
) {
for &spell_id in learned {
let Some(index) = tab_flash_index(spell_id, tab_lines, spells, skill_lines, race, class)
else {
continue;
};
debug!("ui_spellbook: spell {spell_id} landed in tab {index} — flash");
script.fire_event("LEARNED_SPELL_IN_TAB", vec![ScriptValue::Int(index)]);
}
}
/// The event's whole argument law, as a function of the book that was just built: the **1-based**
/// index of `spell_id`'s tab in `tab_lines`, or `None` for a spell that flashes nothing.
///
/// `None` has exactly two causes and both are the reference's: a spell the book add-gate excludes
/// (see [`fire_tab_flashes`] — the three returns before `0x4b29c4`), and a spell whose tab is not
/// in the strip at all, which cannot happen for a booked spell because the strip was built from
/// the same set on the same frame.
fn tab_flash_index(
spell_id: u32,
tab_lines: &[u32],
spells: &Spells,
skill_lines: Option<&SkillLineCatalog>,
race: u8,
class: u8,
) -> Option<i64> {
if !spells
.catalog
.get(spell_id)
.is_some_and(|d| d.in_spellbook())
{
return None;
}
let tab = skill_lines.map_or(NO_LINE, |c| c.spell_tab(spell_id, race, class));
let index = tab_lines.iter().position(|&l| l == tab)?;
Some(index as i64 + 1)
}
/// Build the whole book (module doc): the app's own resolve — the engine holds no spell
/// knowledge, only what's pushed here.
/// Returns the book **and the skill-line id behind each published tab, in the same order** —
/// the second half exists only for [`LearnedInTab`]'s index, and it is returned rather than
/// recomputed so there is exactly one tab ordering in the client (decision 2252).
fn build_book(
known: &BTreeSet<u32>,
catalog: &SpellCatalog,
@@ -298,7 +402,7 @@ fn build_book(
class: u8,
attack_icon: Option<String>,
ranged_icon: Option<String>,
) -> SpellBookState {
) -> (SpellBookState, Vec<u32>) {
let mut by_line: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for &spell_id in known {
// The byte-verified add-gate (module doc; `SpellDisplay::in_spellbook`): languages,
@@ -338,8 +442,10 @@ fn build_book(
lines.sort_by(|a, b| (a.0 != NO_LINE).cmp(&(b.0 != NO_LINE)).then(a.1.cmp(&b.1)));
let mut tabs = Vec::with_capacity(lines.len());
let mut tab_lines = Vec::with_capacity(lines.len());
let mut slots = Vec::new();
for (_, name, texture, mut spell_ids) in lines {
for (line_id, name, texture, mut spell_ids) in lines {
tab_lines.push(line_id);
spell_ids.sort_by_key(|&a| spell_sort_key(catalog, a));
let offset = slots.len() as u32;
let num_spells = spell_ids.len() as u32;
@@ -384,7 +490,7 @@ fn build_book(
num_spells,
});
}
SpellBookState { tabs, slots }
(SpellBookState { tabs, slots }, tab_lines)
}
/// The book comparator `0x4b30c0`'s name → rank tail (module doc; the category-ordinal head is the
@@ -506,6 +612,60 @@ mod tests {
}
}
/// The flash argument: the 1-based index of the spell's tab in the strip `build_book` just
/// published — the reference's `0x4b2b86 inc edi` (decision 2252). With no skill-line catalog
/// every booked spell is in the pinned General tab, which is index 1.
#[test]
fn the_flash_index_is_one_based_into_the_published_tab_strip() {
let mut map = HashMap::new();
map.insert(133, spell("Fireball", Some("Rank 2"), 0x10000));
map.insert(668, spell("Language: Common", None, 0x80)); // DO_NOT_DISPLAY
map.insert(818, spell("Cooking", None, 0x20)); // IS_TRADESKILL
let mut castbar = spell("Some Castbar Spell", None, 0);
castbar.cast_ui = 2;
map.insert(1234, castbar);
let catalog = SpellCatalog::from_displays(map);
let known: BTreeSet<u32> = [133, 668, 818, 1234].into_iter().collect();
let (book, tab_lines) = build_book(&known, &catalog, None, 1, 1, None, None);
assert_eq!(
tab_lines.len(),
book.tabs.len(),
"one line id per published tab"
);
assert_eq!(tab_lines, vec![NO_LINE], "General is the only tab here");
let spells = Spells {
catalog,
..Spells::empty_for_tests()
};
assert_eq!(
tab_flash_index(133, &tab_lines, &spells, None, 1, 1),
Some(1),
"a booked spell flashes its tab, 1-based"
);
// The three the reference returns before ever reaching `0x4b2b92` — bit-for-bit the book
// add-gate, so a spell that is not in the book has no tab to flash.
for (id, why) in [
(668u32, "DO_NOT_DISPLAY returns at 0x4b2911"),
(818, "IS_TRADESKILL returns at 0x4b2944"),
(1234, "castUI > 0 returns at 0x4b29c4"),
] {
assert_eq!(
tab_flash_index(id, &tab_lines, &spells, None, 1, 1),
None,
"{why}"
);
}
assert_eq!(
tab_flash_index(99999, &tab_lines, &spells, None, 1, 1),
None,
"an id with no Spell.dbc record flashes nothing"
);
}
/// The add-gate + General tab through `build_book`, no skill lines (every shown spell lands
/// in the pinned General tab): hidden spells (DO_NOT_DISPLAY / IS_TRADESKILL) never appear,
/// shown spells sort name→rank, and the General tab takes the client's hardcoded name + icon.
@@ -521,7 +681,7 @@ mod tests {
let known: BTreeSet<u32> = [133, 145, 2136, 668, 818].into_iter().collect();
// No skill-line catalog → every shown spell lands in General (race/class irrelevant).
let book = build_book(&known, &catalog, None, 1, 1, None, None);
let book = build_book(&known, &catalog, None, 1, 1, None, None).0;
// One General tab, hardcoded face; only the three shown spells.
assert_eq!(book.tabs.len(), 1);
@@ -562,14 +722,14 @@ mod tests {
"Interface\\Icons\\INV_Sword_04",
"Interface\\Buttons\\Spell-Reset",
] {
let book = build_book(&known, &catalog, None, 1, 1, Some(resolved.into()), None);
let book = build_book(&known, &catalog, None, 1, 1, Some(resolved.into()), None).0;
assert_eq!(book.slots.len(), 1);
assert_eq!(book.slots[0].spell_id, ATTACK);
assert_eq!(book.slots[0].texture.as_deref(), Some(resolved));
}
// No character to read (attack_icon None): falls back to the spell's own icon.
let bare = build_book(&known, &catalog, None, 1, 1, None, None);
let bare = build_book(&known, &catalog, None, 1, 1, None, None).0;
assert_eq!(
bare.slots[0].texture.as_deref(),
Some("Interface\\Icons\\Temp")
@@ -594,7 +754,7 @@ mod tests {
let known: BTreeSet<u32> = [AUTO_SHOT, THROW].into_iter().collect();
let bow = "Interface\\Icons\\INV_Weapon_Bow_02";
let book = build_book(&known, &catalog, None, 1, 1, None, Some(bow.into()));
let book = build_book(&known, &catalog, None, 1, 1, None, Some(bow.into())).0;
let icon_of = |b: &SpellBookState, id: u32| {
b.slots
.iter()
@@ -609,7 +769,7 @@ mod tests {
);
// No ranged weapon: Auto Shot keeps its own icon (no Spell-Reset on the ranged path).
let bare = build_book(&known, &catalog, None, 1, 1, None, None);
let bare = build_book(&known, &catalog, None, 1, 1, None, None).0;
assert_eq!(
icon_of(&bare, AUTO_SHOT).as_deref(),
Some("Interface\\Icons\\Ability_AutoShot")
+43 -3
View File
@@ -642,6 +642,27 @@ enum LastHover {
/// The snapshot fields the unit tooltip's LINES read (everything except the bar's
/// health/power) — the rebuild key: a change here means the rendered lines are stale.
/// The world-hover driver's own memory, bundled — which plate it put up, the line-affecting fields
/// that plate was built from, and the headless probe's say-once-on-change trace line (2255).
///
/// One [`SystemParam`](bevy::ecs::system::SystemParam) rather than three parameters for the reason
/// [`crate::target::hover::GoPickSet`] is one: [`drive_mouseover_tooltip`] sits at Bevy's 16-param
/// function-system ceiling. A bare tuple did the same job and tripped `clippy::type_complexity`,
/// which is the lint asking for exactly this.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct HoverMemo<'s> {
/// Which world plate is currently up — the driver's "do not rebuild this every frame" memo.
/// **Faithful**: on an unchanged mouseover the reference makes no call at all (`0x482090`
/// returns at `0x4820b5`), so a plate Lua takes mid-hover staying gone is reference behaviour
/// and not a bug to fix here. See 2255.
last: Local<'s, crate::ui_script::VmMemo<LastHover>>,
/// The line-affecting fields the current unit plate was built from, so a late-arriving name or
/// creature-info rebuilds it under the same hover.
last_lines: Local<'s, crate::ui_script::VmMemo<Option<UnitState>>>,
/// The headless probe's last trace line, so a stationary probe says it once (2255).
trace: Local<'s, String>,
}
fn lines_view(s: &UnitState) -> UnitState {
UnitState {
health: 0,
@@ -701,8 +722,7 @@ fn drive_mouseover_tooltip(
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam (0582/0584): the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut last: Local<crate::ui_script::VmMemo<LastHover>>,
mut last_lines: Local<crate::ui_script::VmMemo<Option<UnitState>>>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16 — `UnitHasRelicSlot`'s only input. Absent when the client data
// failed to load, in which case no class reads as having a relic slot.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
@@ -710,6 +730,7 @@ fn drive_mouseover_tooltip(
let Some(mut script) = script else {
return;
};
let (last, last_lines, trace) = (&mut memo.last, &mut memo.last_lines, &mut *memo.trace);
let last = last.get(&script);
let last_lines = last_lines.get(&script);
let self_store = self_q.iter().next();
@@ -829,6 +850,15 @@ fn drive_mouseover_tooltip(
// pick out the same set here. They diverge only for the three always-eligible types
// (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25), which is exactly where a pin would
// settle it. Flagged INTERIM in 0766 rather than presented as verified.
//
// **THE PIN HAS ARRIVED, AND IT IS NARROWER THAN THIS LINE** (2255): `[vtbl+0x5c]` is
// `0x5f8630` = `template.data[0x621b00(type, 0x13)] != 0`, and semantic key `0x13` exists
// on exactly ONE of the 31 types — GENERIC(5), at `data[0]`. So the cursor arm is "GENERIC
// **with `data[0]` set**", and a GENERIC with it clear is corner-seated; the three
// always-eligible types above are corner-seated too, so 0766's "GENERIC" reading wins over
// its "not interactable" one. Applying it moves 190 of the 1387 hoverable type-5 templates
// from the cursor to the corner — a visible change, so it is its own slice and not a
// passenger on a bug fix (2255 carries the counts and the reasoning).
let cursor_seated =
stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC);
// Window px → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) —
@@ -842,12 +872,22 @@ fn drive_mouseover_tooltip(
// (2250), so an automated run can carry a GENERIC plate — which is
// cursor-seated — all the way to the screen. A person's pointer always wins.
w.cursor_position()
.or_else(|| crate::target::hover_probe_point(w))
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
})
})
.flatten();
if *last == LastHover::Go(guid) {
if crate::target::hover_probe_armed() {
let line = format!(
"held Go({guid:#x}) — plate up {}",
script.world_tooltip_up()
);
if *trace != line {
info!("hover probe/tooltip: {line}");
*trace = line;
}
}
// The cursor arm follows the pointer; the corner arm has nothing to re-seat.
if let Some((x, y)) = cursor_ui {
script.world_tooltip_move(x, y);
+45 -3
View File
@@ -31,7 +31,7 @@
use mlua::{Lua, Table};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty};
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty, tip_mut};
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
@@ -397,7 +397,7 @@ impl super::UiScript {
lines: &[(String, TooltipTint)],
cursor: Option<(f32, f32)>,
) -> bool {
let (h, id, root_id) = {
let (h, id, root, root_id) = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
@@ -406,13 +406,37 @@ impl super::UiScript {
return false;
};
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
(h, id, root_id)
(h, id, root, root_id)
};
match cursor {
// The cursor arm: seated centred above the pointer, clamped by the tooltip frame's own
// flag. Compare-then-touch so a still pointer never re-layouts.
Some((ui_x, ui_y)) => {
let mut model = self.model_mut();
// **The OWNER, which this arm used to skip** (decision 2255). The
// reference's cursor arm is `0x492a01 → 0x52ffe0(owner, 6, 0, 0)` — the SetOwner
// CORE, not a bare re-anchor — and the core's `0x53000c` stores that owner into
// `+0x314`. Every OTHER world plate we build reaches an owner by accident: the
// corner arm below and the unit flow both fire `OnTooltipSetDefaultAnchor`, whose
// FrameXML handler calls `GameTooltip:SetOwner(UIParent, …)` in Lua. This arm
// fires nothing, so `+0x314` stayed NULL on exactly the cursor-seated objects —
// a signpost, a mailbox, every GENERIC(5).
//
// A null owner is not cosmetic, because **`:Show()` is an existence gate**:
// `0x530a80` shows only when `+0x314` AND `+0x31c` are both non-zero, and
// otherwise takes the effective-hide `0x530a60` — which we implement faithfully
// (see the `Show` verb). So any Lua that reached `GameTooltip:Show()` while one of
// these plates was up HID IT, and hid it through our own correct code.
//
// That is not hypothetical: `!Questie` hooks GameTooltip's `OnShow` at
// PLAYER_LOGIN (`Questie:hookTooltip`, installed because stock GameTooltip has no
// OnShow of its own) and its handler ends in `GameTooltip:Show()`. So the plate's
// own show event hid the plate, ~26 ms after the engine built it, on every
// signpost, for the whole session — the director's report. The reference cannot
// reach that state: `0x492a01` writes the owner before the plate is ever shown.
if let Ok(t) = tip_mut(&mut model, h) {
t.owner = Some(root);
}
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
@@ -585,6 +609,24 @@ impl super::UiScript {
}
}
/// **Is `GameTooltip` still the plate a world hover put up?** — world-owned (no Lua
/// `SetOwner`, no content `Set*` and no `Hide` has taken it since) and still shown.
///
/// The world-hover driver keeps a host-side memo of which plate it put up so it does not
/// rebuild one per frame; that memo is about the HOVER, and this is the other half of the
/// question — whether the plate that memo describes is still on the screen.
pub fn world_tooltip_up(&mut self) -> bool {
let model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
model
.arena
.frame(h)
.map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown)
.unwrap_or(false)
}
/// Arm the mouseover tooltip's fade-out (hover loss — the byte law arms a timestamped fade,
/// never an instant hide). No-op when the tooltip isn't showing a world unit.
pub fn world_tooltip_fade(&mut self) {