clicks find their target, the booth eases off, and the map's lines go

- the world camera's pick regression: its off-screen render target carried `scale_factor: 1.0`, so on a Retina display the camera reported physical pixels as logical and a centred cursor cast its pick ray into the upper-left quadrant — unit and GameObject picks, the occlusion ray, chat bubbles and `world_to_viewport` all broke at once; the target now carries the window's real scale factor, re-stamped when dragging between displays changes it mid-session — and the perf pill's overlap dodge had the same disease in miniature (`GetBottom()` answers in WoW UI units, the pill lays out in window px), so it now answers a fraction of the screen and works at any window height
- `boothHalfRate` defaults back to half-rate: a body pane's off-screen booth pass rendered every frame reads +7.6 ms of GPU at 4K (~1.6 ms at 1600×900), on exactly the weak GPUs that felt the last snapshot — the doll's item effects keep full speed, since they key on the pacing regime rather than the camera's active bit, so only the 30 fps animation cadence returns, and full-rate stays one `SetCVar` away
- a spell cast by a GameObject arrives with caster guid 0 — vmangos writes the caster slot from a `Unit*` its GameObject constructor never sets — and the whole visual body was dropped at the guid lookup; the decode now reads an empty caster slot as the packet's first guid, which with no cast item IS the caster, so the Lightwell's click plays its impact kit (sound 3155 and the head flash) instead of only the aura glow that hid the miss, and the object stops being mislabelled a cast item
- the world map's black hairlines: a `SetTexCoord` crop is not a texture, and a POI icon magnified to 2.08 px per texel sampled half a texel past its crop into the neighbouring atlas cell — an opaque black row bleeding over a cell that is fully transparent (the empty cell is the reference's own zone-level substitution); `UiQuad::uv_clamp` now insets the sampling window half a texel per axis, so a crop renders as if its sub-rect were its own clamped texture, and `WOW_UI_PICK=x,y` names every quad under a pixel — it found this one in a single run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-27 15:09:56 +07:00
co-authored by Claude Fable 5
parent 3697e85525
commit 027a2a14b4
15 changed files with 530 additions and 58 deletions
+4 -3
View File
@@ -643,9 +643,10 @@
-- Remove), a **DISPLAY** header over Font Size and Background, a **FILTERS** header over
-- seven message-group submenus, and Join New Channel.
--
-- benilla ships the DISPLAY block, and that is the honest tree rather than a first pass:
-- every window verb needs the window management 0288 §2 deliberately left out (there is
-- no lock, no rename, no undock, no create/close), and the FILTERS block needs a runtime
-- benilla ships the Lock/Unlock row (1594 built the move, the resize and both lock
-- scopes under it) and the DISPLAY block, and that is the honest tree rather than a first
-- pass: the OTHER three window verbs still need the window management 0288 §2 left out
-- (there is no rename, no undock, no create/close), and the FILTERS block needs a runtime
-- message-group editor over `ui_chat::frames`' registration, which is its own feature and
-- not the one B246 reports missing. Each block lands with the machinery under it.
--
@@ -646,7 +646,12 @@
-- CheckButton 7 → 8 → 13 → 18 → 37 → 46 → 38 → 51 → 68), with the rows we have no
-- mechanism for skipped rather than stubbed:
-- · 7 Simple Chat — needs FCF_Set_SimpleChat's re-layout (0288 §2)
-- · 8 Lock Chat Settings — needs drag/undock/resize to lock (0288 §2)
-- · 8 Lock Chat Settings — the ONE row here whose machinery now exists: 1594
-- built the move, the resize and the blanket `CHAT_LOCKED` switch this
-- checkbox drives (`FCF_Set_ChatLocked`/`FCF_Get_ChatLocked`, gating both
-- verbs). It is unbuilt because nobody has asked for it, not because it
-- would be inert — and the per-window lock the tab menu carries is the one
-- a player actually reaches for. Ships when it is wanted
-- · 51 Auto-join Guild Recruitment Channel — carries **no cvar, uvar or func in the
-- reference either** (UIOptionsFrame.lua l.51 is a bare `{ index = 51 }`), so
-- it is inert there; a working row here would be an invention, not a fix
+5 -5
View File
@@ -214,11 +214,11 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// the doll renders at half the frame rate while its pane is open; the knob is
// [`crate::portrait::PaneRate`], and the default mirrors it (welded below).
//
// **Default OFF since 1559.** 1444 shipped it on and named the condition for turning it
// back off — "whether a 30 fps doll *reads* right is the director's call". B312 is that
// call: the reference draws its doll every frame, so every frame is the faithful default
// and the ~1.6 ms/frame is one `SetCVar` away for anyone who wants it back.
("boothHalfRate", "0"),
// **Default ON (half-rate) — restored by 1607.** 1444 shipped it on; 1559 turned it off for
// a smoother doll (a look-call); the 08-25 weak-GPU perf reports (B329) measured the cost —
// ~1.6 ms at 1600×900, 7.6 ms at 4K, per frame while a body pane is open — and the director
// retested the 30 fps doll as fine. Full-rate is one `SetCVar("boothHalfRate", 0)` away.
("boothHalfRate", "1"),
];
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
+4 -2
View File
@@ -410,8 +410,10 @@ pub(super) fn spell_go(
// the server answered a `CMSG_GAMEOBJ_USE` with. `caster_indexed=false` is the load-bearing
// case — every impact, sound and effect model below hangs off `index.0.get(&caster)`, so a
// caster we never streamed drops the whole visual silently. A GameObject IS the caster for a
// SPELLCASTER-type object (vmangos leaves `spellCaster = this` for type 22), which is why this
// reads the index rather than assuming a unit.
// SPELLCASTER-type object (vmangos leaves `spellCaster = this` for type 22), and it reaches
// here as a real guid only because the decode seam resolves the pair: vmangos writes the caster
// slot from a null `m_casterUnit` for such a cast and the wire carries guid **0**
// (`benilla_protocol::events`' `spell_caster`).
if benilla_assets::trace::enabled_for("use") {
benilla_assets::trace::line(
"use",
+19 -8
View File
@@ -163,9 +163,18 @@ pub(super) fn refresh_hud_snapshot(
///
/// **Asked of the frame, not recomputed from its numbers.** Its row pitch and anchor live in
/// `assets/ui/WorldStateFrame.xml`; mirroring them here would be two copies to keep in step, so
/// this reads the resolved edge the layout actually produced (`GetBottom` — local units, y-up,
/// and benilla has no `uiScale` CVar so UIParent's are screen px). One tiny chunk at 4 Hz, only
/// while the HUD is drawing — the same shape as [`crate::hover_log`]'s tooltip probe.
/// this reads the resolved edge the layout actually produced (`GetBottom`, y-up). One tiny chunk
/// at 4 Hz, only while the HUD is drawing — the same shape as [`crate::hover_log`]'s tooltip
/// probe.
///
/// **Two coordinate spaces meet here, and mixing them is the whole trap.** The layout answers in
/// WoW UI units — a screen that is always `768/uiScale` units tall whatever the window is
/// (decision 0582's seam) — while the pill lays itself out in window px. Subtracting one from the
/// other is right only in the single case where the seam scale happens to be 1 (a 768 px-tall
/// window at uiScale 1) and wrong by `windowH − 768` everywhere else — which on the director's
/// window seated the pill back on top of the readout's third row while every test at 768 passed.
/// So the chunk answers a **fraction of the screen**, which belongs to neither space, and the
/// caller multiplies by the window height it actually draws in.
///
/// **Its own cost, stated (1370's rule for this overlay):** an edge read settles the layout, so on
/// a frame where something has written anchors since the last resolve this pays one graph solve
@@ -174,14 +183,16 @@ pub(super) fn refresh_hud_snapshot(
pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 {
const CHUNK: &str = r#"
local f = WorldStateAlwaysUpFrame
if f and f:IsVisible() and f:GetBottom() then return f:GetBottom() end
return -1
if not (f and f:IsVisible()) then return -1 end
local bottom, screen = f:GetBottom(), GetScreenHeight()
if not bottom or not screen or screen <= 0 then return -1 end
return (screen - bottom) / screen
"#;
let bottom: f32 = script.eval::<f64>(CHUNK).unwrap_or(-1.0) as f32;
if bottom < 0.0 {
let frac: f32 = script.eval::<f64>(CHUNK).unwrap_or(-1.0) as f32;
if !(0.0..=1.0).contains(&frac) {
return 0.0;
}
(win_h - bottom).clamp(0.0, win_h)
frac * win_h
}
/// The pill as ~20 quads on the player-UI pass (decision 1453). The append lane is rebuilt every
+24 -11
View File
@@ -836,26 +836,39 @@ fn feed_gx_aspect(
/// touched); only the camera skips, so the held frame is always one the pose just produced.
///
/// `boothHalfRate` is **benilla's own CVar** (the reference has no second view to rate-limit —
/// its doll draws in the main pass, 1069's known rent). **Default OFF since 1559**: 1444 shipped
/// it on and named the condition for the reverse — "whether a 30 fps doll *reads* right is the
/// director's call (§7)" — and B312 is that call. Every frame is the faithful default; the
/// ~1.6 ms/frame is one `/script SetCVar("boothHalfRate", 1)` away.
/// its doll draws in the main pass, 1069's known rent). **Default ON (half-rate) — restored by
/// 1607.** 1444 shipped it on; 1559 turned it off on the director's look-call (a full-rate doll
/// reads as smoother); the 08-25 weak-GPU perf reports (B329) then measured what that costs — a
/// body-pane booth's off-screen pass every frame, ~1.6 ms at 1600×900 and **7.6 ms at 4K**
/// (`sess/perfregress` A/B), paid on exactly the weak GPUs that reported. The director retested
/// the 30 fps doll and it reads fine, so the cheaper default is back. Full-rate is one
/// `/script SetCVar("boothHalfRate", 0)` away for anyone who wants the smoother cadence.
///
/// Turning it on no longer slows the item effects. Booth-lane emitters used to freeze on the
/// camera's own `is_active` bit and so ticked once per *drawn* frame — the draw-set law read
/// onto a skip the reference never makes. They key on
/// Half-rate no longer slows the item effects (why 1559's revert was safe to make, and this one).
/// Booth-lane emitters used to freeze on the camera's own `is_active` bit and so ticked once per
/// *drawn* frame — the draw-set law read onto a skip the reference never makes. They key on
/// [`benilla_world::particles::ViewThrottled`] now: a paced camera's scene is still live, so the
/// sim runs at full rate and only the render is halved (1559).
/// sim runs at full rate and only the render is halved (1559). So B312 does not return: only the
/// doll's animation *cadence* in the pane drops to 30 fps, never its item/weapon effects.
///
/// The glue screens (`live_scene`) are exempt: a fullscreen create/select scene at 30 fps is
/// not a pane crop, and those screens have no world behind them to pay for.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct PaneRate {
/// Off by default — see the type's doc. The registered CVar's "0" is welded to this in
/// `cvars::tests`, so the shipped default cannot drift from the registered one.
/// The registered CVar is welded to this in `cvars::tests`, so the shipped default cannot
/// drift from the registered one.
pub(crate) half: bool,
}
impl Default for PaneRate {
/// Half-rate (1607) — see the type's doc. `Default` is hand-written rather than derived so the
/// shipped default is not silently `false`; the welded `cvars::tests` assert pins it to the
/// registered CVar either way.
fn default() -> Self {
Self { half: true }
}
}
/// The two **framing inputs** a body booth reads, in one param: the pane geometry the UI extract
/// publishes ([`BoothPanes`]) and the display aspect ([`GxAspect`]).
///
+19 -1
View File
@@ -98,6 +98,18 @@
// `linear_to_srgb` has no decode to undo: the texel passes through as-is. The alpha-test arm
// ignores this flag — its target is the un-encoded composite, so it decodes explicitly instead.
@group(2) @binding(10) var<uniform> gamma_texel: u32;
// The **UV window this quad may sample** — `(u_min, v_min, u_max, v_max)`, already inset by half a
// texel by the producer; an axis whose `min > max` is left unclamped. The atlas-cell guard,
// decision 1608.
//
// `CLAMP_TO_EDGE` clamps at the IMAGE's edge, not at a `SetTexCoord` crop's, so a magnified atlas
// cell's outermost destination pixels sample half a texel past the crop and linear-filter in the
// NEIGHBOURING cell. `POIIcons` cell 15 (the generic zone landmark) is fully transparent and the
// cell above it is a coffin whose bottom row is opaque black: the world map drew a ~24%-black
// hairline along the top edge of every zone POI. Clamping here makes a cell sample exactly what a
// standalone clamped texture of it would. Producers leave it off for UVs that run past `[0,1]` —
// that is the backdrop's tiling idiom, not a cell.
@group(2) @binding(11) var<uniform> uv_clamp: vec4<f32>;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
@@ -128,7 +140,13 @@ fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let t = textureSample(quad_texture, quad_sampler, in.uv);
// Branchless so the sample stays in uniform control flow (implicit derivatives), and the
// bounds are sorted before `clamp` so a DISABLED (`min > max`) axis never forms an
// ill-ordered range — `select` evaluates both arms.
let lo = min(uv_clamp.xy, uv_clamp.zw);
let hi = max(uv_clamp.xy, uv_clamp.zw);
let uv = select(in.uv, clamp(in.uv, lo, hi), uv_clamp.xy <= uv_clamp.zw);
let t = textureSample(quad_texture, quad_sampler, uv);
#ifdef VERTEX_COLORS
let c = in.color;
#else
+37
View File
@@ -207,6 +207,25 @@ pub(crate) struct UiQuad {
/// two filtered edges are complementary — which is B141's "odd black lines" (they recoloured
/// with the backing quad, which is how they were caught). Splits the batch run.
pub alpha_test: Option<f32>,
/// **The UV window this quad may sample**, already inset by half a texel — `None` = the
/// sampler's own `ClampToEdge` is the whole story (decision 1608).
///
/// A `SetTexCoord` crop into an ATLAS is not a texture: `CLAMP_TO_EDGE` clamps at the
/// image's edge, not the cell's, so a magnified cell's outermost row of destination pixels
/// samples half a texel PAST its crop and linear-filters in whatever the neighbouring cell
/// authored there. `Interface\Minimap\POIIcons` is the case that forced this: cell 15 (the
/// generic zone-level landmark) is fully transparent, the cell directly above it is the
/// coffin whose bottom row is OPAQUE BLACK, and the world map drew a ~24%-black hairline
/// across the top edge of every zone POI — the director's "black horizontal lines".
///
/// Set it and the fragment clamps into `[u_min, v_min, u_max, v_max]`, which is exactly what
/// a standalone clamped texture of that cell would sample. The producer decides *whether* a
/// crop is a cell (see the extract's `uv_clamp_window`): an axis whose UVs run past `[0,1]`
/// is the reference's TILING idiom and must never be clamped, which is why the window is
/// disabled **per axis** rather than per quad.
///
/// Splits the batch run like `additive`/`circular` — it rides the material, not the vertex.
pub uv_clamp: Option<[f32; 4]>,
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
pub clip: Option<Rect>,
/// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's
@@ -255,6 +274,7 @@ impl Default for UiQuad {
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
clip: None,
rotation: 0.0,
mask: None,
@@ -399,6 +419,10 @@ pub(crate) struct UiQuadMaterial {
#[texture(5)]
#[sampler(6)]
mask: Option<Handle<Image>>,
/// The half-texel-inset UV window the fragment may sample — `(u_min, v_min, u_max, v_max)`,
/// **per axis**, with `min > max` on an axis disabling that axis. See [`UiQuad::uv_clamp`].
#[uniform(11)]
uv_clamp: Vec4,
}
impl UiQuadMaterial {
@@ -424,6 +448,8 @@ impl UiQuadMaterial {
gamma_texel: 0,
mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0),
mask: None,
// A tile samples its whole image; there is no cell to stay inside of.
uv_clamp: UV_CLAMP_OFF,
}
}
}
@@ -675,6 +701,7 @@ struct Run {
gamma_texel: bool,
alpha_test: Option<f32>,
mask: Option<UiQuadMask>,
uv_clamp: Option<[f32; 4]>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
colors: Vec<[f32; 4]>,
@@ -694,6 +721,7 @@ impl Run {
gamma_texel: q.gamma_texel,
alpha_test: q.alpha_test,
mask: q.mask.clone(),
uv_clamp: q.uv_clamp,
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
@@ -856,8 +884,13 @@ type MatKey = (
u32,
Option<AssetId<Image>>,
[u32; 4],
[u32; 4],
);
/// The [`UiQuadMaterial::uv_clamp`] that clamps NEITHER axis — `min > max` on an axis is the
/// shader's per-axis "off", so `(1,1)` against `(0,0)` disables both.
const UV_CLAMP_OFF: Vec4 = Vec4::new(1.0, 1.0, 0.0, 0.0);
/// Retire every pooled batch entity (blank frame / no drawable content). Mesh handles and the
/// material cache stay — assets referenced only by the pool cost nothing to keep and come back
/// for free.
@@ -1060,6 +1093,7 @@ fn rebuild_ui_mesh(
&& r.gamma_texel == q.gamma_texel
&& r.alpha_test == q.alpha_test
&& r.mask == q.mask
&& r.uv_clamp == q.uv_clamp
});
if !same_run {
runs.push(Run::new(texture, q));
@@ -1123,6 +1157,7 @@ fn rebuild_ui_mesh(
);
}
let alpha_ref = run.alpha_test.unwrap_or(0.0);
let uv_clamp = run.uv_clamp.map_or(UV_CLAMP_OFF, Vec4::from_array);
let key: MatKey = (
run.texture.id(),
run.additive,
@@ -1133,6 +1168,7 @@ fn rebuild_ui_mesh(
alpha_ref.to_bits(),
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
uv_clamp.to_array().map(f32::to_bits),
);
// The per-slot skip gate (decision 1361). A rebuild fires for the WHOLE quad stream the
// moment anything differs — and one continuously-animating quad (the resting blink on
@@ -1254,6 +1290,7 @@ fn rebuild_ui_mesh(
gamma_texel: u32::from(run.gamma_texel),
mask_rect,
mask,
uv_clamp,
})
})
.clone();
@@ -45,6 +45,111 @@ fn ui_diff_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_UI_DIFF").is_some())
}
/// `WOW_UI_PICK=<x>,<y>[,<r>]` — **what drew this pixel?** The UI's answer to the world hover
/// inspector. Every converted quad whose screen rect covers that point (or, with `r`, comes
/// within `r` px of it — how a hairline thinner than the probe is caught) names itself once: the
/// `ZTarget` (frame or region handle), the rect, the paint key, and the content — a texture's
/// BLP path and crop included. Coordinates are LOGICAL window px, y-down from the top-left, the
/// same space [`convert_entry`]'s `rect` lives in (a 2x-scale capture's device pixel is half
/// this). Each `z_key` reports once per run, so a 200-frame capture prints the list once.
///
/// Built for a hairline nobody could name (the world map's dark seams): without it, "which
/// region is that one dark row" costs a bisect through FrameXML with a rebuild per guess.
fn ui_pick_point() -> Option<(Vec2, f32)> {
static AT: std::sync::OnceLock<Option<(Vec2, f32)>> = std::sync::OnceLock::new();
*AT.get_or_init(|| {
let raw = std::env::var("WOW_UI_PICK").ok()?;
let mut it = raw.split(',');
let x: f32 = it.next()?.trim().parse().ok()?;
let y: f32 = it.next()?.trim().parse().ok()?;
let r = it.next().and_then(|v| v.trim().parse().ok()).unwrap_or(0.0);
Some((Vec2::new(x, y), r))
})
}
/// One `[ui-pick]` line per covering quad — see [`ui_pick_point`]. Deduped by paint key so a
/// steady UI reports its stack once, not once a frame.
fn report_ui_pick(eq: &benilla_ui::script::ExtractedQuad, rect: Rect, at: Vec2, r: f32) {
use std::collections::HashSet;
use std::sync::Mutex;
static SEEN: std::sync::OnceLock<Mutex<HashSet<u64>>> = std::sync::OnceLock::new();
if !rect.inflate(r).contains(at) {
return;
}
if !SEEN
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(eq.z))
.unwrap_or(false)
{
return;
}
let what = match &eq.content {
QuadContent::Frame => "frame-slot".to_string(),
QuadContent::Minimap { .. } => "minimap".to_string(),
QuadContent::Cooldown { .. } => "cooldown".to_string(),
QuadContent::Texture {
path,
color,
tex_coords,
..
} => format!(
"texture {:?} color={color:?} crop={tex_coords:?}",
path.as_deref().unwrap_or("<none>")
),
other => format!("{other:?}"),
};
info!(
"[ui-pick] {:?} z={} rect=({:.2},{:.2})-({:.2},{:.2}) alpha={:.3} {what}",
eq.target, eq.z, rect.min.x, rect.min.y, rect.max.x, rect.max.y, eq.alpha,
);
}
/// The half-texel-inset UV window a **cropped** quad may sample — [`UiQuad::uv_clamp`]'s producer
/// (decision 1608), `None` when neither axis needs one.
///
/// `CLAMP_TO_EDGE` clamps at the IMAGE's edge; a `SetTexCoord` crop into an ATLAS has no such
/// guard, so a magnified cell's outermost destination pixels sample half a texel past the crop and
/// linear-filter in whatever the neighbouring cell authored. Half a texel of inset is exactly where
/// a standalone clamped texture of that cell stops — the edge texel's CENTRE.
///
/// Decided **per axis**, and the two tests are the whole law:
/// - an axis running past `[0,1]` is the reference's TILING idiom (`SetTexCoord(0, n, 0, 1)` on the
/// stance shelf repeats the art n times); insetting it would walk the art along the run, so it is
/// left alone — the same bounded-axis rule [`benilla_ui::script::inset_atlas_bleed`] applies to
/// the `Backdrop` slices;
/// - an axis spanning the whole texture needs nothing: the sampler's own clamp already is this.
fn uv_clamp_window(uv: &UvRect, size: (u32, u32)) -> Option<[f32; 4]> {
let mut out = [1.0, 1.0, 0.0, 0.0]; // both axes off — `min > max` (see `UiQuad::uv_clamp`)
let mut any = false;
for (axis, texels) in [(0usize, size.0), (1usize, size.1)] {
if texels < 2 {
continue; // a 1-texel axis has no interior to inset toward
}
let (lo, hi) = uv.corners.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| {
(lo.min(c[axis]), hi.max(c[axis]))
});
// Outside the texture (tiling), or the whole of it (the sampler's clamp is the window).
if lo < -0.001 || hi > 1.001 || (lo <= 0.001 && hi >= 0.999) {
continue;
}
let half = 0.5 / texels as f32;
// A crop under one texel wide has no interior either: pin both bounds to its centre, which
// is the one texel it means.
let (lo, hi) = match hi - lo > 2.0 * half {
true => (lo + half, hi - half),
false => {
let mid = 0.5 * (lo + hi);
(mid, mid)
}
};
out[axis] = lo;
out[axis + 2] = hi;
any = true;
}
any.then_some(out)
}
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
/// it is never settled, this says what is moving.
@@ -858,6 +963,9 @@ fn convert_entry(
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
// y-down window px from the top-left — scale ×s, then flip through the window height.
let rect = Rect::new(r.left * s, h - r.top * s, r.right * s, h - r.bottom * s);
if let Some((at, r)) = ui_pick_point() {
report_ui_pick(&eq, rect, at, r);
}
// The ScrollFrame clip (decision 0112), through the same conversion as `rect` —
// `UiQuad::clip` is the CPU-clip stand-in `ui_pass` already applies uniformly to
// every quad (texture, backdrop, and glyph alike), so this is the entire app-side plumb.
@@ -1057,6 +1165,16 @@ fn convert_entry(
}
_ => None,
};
// The atlas-cell guard (decision 1608): a `SetTexCoord` crop is a cell of a sheet,
// and bilinear magnification reaches past it into the neighbour unless the fragment
// is told where the cell ends. The world map's zone POIs are the case that forced it
// — `POIIcons` cell 15 is fully transparent and the cell above it is a coffin whose
// bottom row is opaque black, so every zone landmark wore a black hairline.
let uv_clamp = handle
.as_ref()
.and_then(|h| images.get(h))
.map(|img| img.texture_descriptor.size)
.and_then(|sz| uv_clamp_window(&uv, (sz.width, sz.height)));
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
@@ -1065,6 +1183,7 @@ fn convert_entry(
z_key: eq.z,
texture: handle,
uv,
uv_clamp,
color,
additive,
clip,
@@ -1266,6 +1385,71 @@ fn cursor_icon_quad(pos: Vec2, texture: Handle<Image>) -> UiQuad {
}
}
/// The atlas-cell guard's law (decision 1608) — see [`uv_clamp_window`]. Each case is a shape the
/// shipped UI actually draws, and the three that return `None` are the three ways a crop is not a
/// cell.
#[cfg(test)]
mod uv_clamp_tests {
use super::{uv_clamp_window, UvRect};
/// The bug's own numbers: `POIIcons` is 128², a world-map POI samples cell (7,1), and the
/// window has to stop half a texel (`0.5/128`) inside it — a hair below texel row 16's centre
/// is where the coffin above stopped leaking in.
#[test]
fn a_poi_icons_cell_stops_half_a_texel_inside_itself() {
let uv = UvRect::from_tex_coords([0.875, 1.0, 0.125, 0.25]);
let w = uv_clamp_window(&uv, (128, 128)).expect("an atlas cell is clamped");
let half = 0.5 / 128.0;
assert!((w[0] - (0.875 + half)).abs() < 1e-6, "u_min {}", w[0]);
assert!((w[1] - (0.125 + half)).abs() < 1e-6, "v_min {}", w[1]);
assert!((w[2] - (1.0 - half)).abs() < 1e-6, "u_max {}", w[2]);
assert!((w[3] - (0.25 - half)).abs() < 1e-6, "v_max {}", w[3]);
}
/// The whole texture is already clamped by the sampler — a window here would only cost a
/// batch split.
#[test]
fn the_whole_texture_asks_for_no_window() {
assert!(uv_clamp_window(&UvRect::FULL, (128, 128)).is_none());
}
/// `SetTexCoord(0, n, 0, 1)` on an n-slot strip is the reference's TILING idiom (the stance
/// shelf): the repeating axis keeps its exact period, the bounded one still gets its window.
#[test]
fn a_tiling_axis_is_left_alone_while_its_bounded_partner_is_not() {
let uv = UvRect::from_tex_coords([0.0, 3.0, 0.0, 0.5]);
let w = uv_clamp_window(&uv, (64, 64)).expect("the v axis is a bounded crop");
assert!(w[0] > w[2], "u tiles, so its axis must read as OFF: {w:?}");
assert!((w[1] - 0.5 / 64.0).abs() < 1e-6);
assert!((w[3] - (0.5 - 0.5 / 64.0)).abs() < 1e-6);
}
/// A mirrored slice (`left > right` — the PlayerFrame ring) is still one cell: the window is
/// built from the corner EXTENTS, so the flip survives it untouched.
#[test]
fn a_mirrored_slice_is_clamped_by_its_extents() {
let uv = UvRect::from_tex_coords([0.5, 0.25, 0.0, 1.0]);
let w = uv_clamp_window(&uv, (64, 64)).expect("a mirrored cell is still a cell");
assert!((w[0] - (0.25 + 0.5 / 64.0)).abs() < 1e-6);
assert!((w[2] - (0.5 - 0.5 / 64.0)).abs() < 1e-6);
assert!(
w[1] > w[3],
"v spans the whole texture, so it reads as OFF: {w:?}"
);
}
/// A crop thinner than one texel has no interior to inset toward — both bounds pin to its
/// centre, which is the single texel it means (and stays a VALID, enabled range).
#[test]
fn a_sub_texel_crop_pins_to_the_texel_it_names() {
let uv = UvRect::from_tex_coords([0.5, 0.505, 0.0, 1.0]);
let w = uv_clamp_window(&uv, (64, 64)).expect("still a crop");
assert!((w[0] - 0.5025).abs() < 1e-6);
assert!(w[0] <= w[2], "the axis must stay enabled: {w:?}");
assert!((w[2] - 0.5025).abs() < 1e-6);
}
}
#[cfg(test)]
mod cursor_quad_tests {
use super::cursor_icon_quad;
@@ -162,6 +162,7 @@ pub(super) fn emit(
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -348,6 +349,7 @@ pub(super) fn emit(
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -321,7 +321,13 @@ fn the_bindings_answer_the_reference_shape() {
/// resolved bottom once it is up.
#[test]
fn the_readout_tells_the_dev_pill_how_much_of_the_top_it_uses() {
const SCREEN_H: f32 = 768.0;
// NOT 768. The layout answers in WoW UI units — a screen that is always 768 units tall
// whatever the window is (decision 0582) — and the pill draws in window px, so a probe that
// subtracts one from the other is right only when the two happen to coincide. Feeding a
// window height that is NOT the virtual one is the whole point of this test: it is what the
// director's client does, and the first version of this probe put the pill back on top of the
// readout's third row there while passing every test at 768.
const SCREEN_H: f32 = 900.0;
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
s.resolve();
@@ -355,8 +361,9 @@ fn the_readout_tells_the_dev_pill_how_much_of_the_top_it_uses() {
// The frame's own geometry, read back the way the probe reads it: the container's top offset
// plus one row per pushed row. Asserted against the XML rather than restated as constants.
let expected: f32 = s
.eval::<f64>("return 20 + WORLD_STATE_ROW_HEIGHT * 2 + 15")
.expect("the frame's own numbers") as f32;
.eval::<f64>("return (20 + WORLD_STATE_ROW_HEIGHT * 2 + 15) / GetScreenHeight()")
.expect("the frame's own numbers") as f32
* SCREEN_H;
assert!(
(claimed - expected).abs() < 0.5,
"the claim is the readout's resolved bottom: {claimed} vs {expected}"
+108 -8
View File
@@ -69,6 +69,10 @@ pub(crate) struct WorldBackdrop {
pub(crate) image: Handle<Image>,
/// The size the image was last built at, in physical px — the resize gate.
size: UVec2,
/// The scale factor the camera's target was last stamped with — the re-stamp gate, and the
/// number that keeps every logical↔physical conversion on the world camera honest. See
/// [`retarget_world_camera`].
scale_factor: f32,
}
/// A fresh backdrop image at `size` physical px. See the module doc for the format's two halves.
@@ -106,7 +110,13 @@ fn setup_backdrop(
.map(window_physical_size)
.unwrap_or(UVec2::new(1280, 720));
let image = images.add(new_backdrop_image(size));
commands.insert_resource(WorldBackdrop { image, size });
commands.insert_resource(WorldBackdrop {
image,
size,
// Deliberately not the window's: `retarget_world_camera` stamps the real one and must see
// a mismatch on its first run, before any camera exists to point at the image.
scale_factor: f32::NAN,
});
}
/// Keep the backdrop the window's size. A stale-sized backdrop would still *work* (the quad covers
@@ -131,22 +141,55 @@ fn track_window_size(
}
}
/// Point the world camera at the backdrop instead of the swapchain.
/// Point the world camera at the backdrop instead of the swapchain — **carrying the window's own
/// scale factor**.
///
/// A system rather than a component on the spawn, because there are two spawn sites for the same
/// camera (the real one and `player::setup`'s no-client-data fallback) and neither should have to
/// know about the composite lane. `benilla-worldview` is unaffected — it links `benilla-world`, not
/// this crate, and has no UI camera to composite with, so its world camera keeps the swapchain.
///
/// `Added` rather than a per-frame compare: `RenderTarget` carries no `PartialEq`, and nothing in
/// the tree rewrites a world camera's target once set (the only other `Camera` writer is the gate
/// that flips `is_active`). Retarget it when it appears, and leave it alone after.
/// **The scale factor is the load-bearing half.** A camera's logical↔physical conversions all go
/// through its target's `RenderTargetInfo`: a WINDOW target reports the window's physical size and
/// the window's scale factor, so `logical_viewport_size` is the window's LOGICAL size — which is
/// the space `Window::cursor_position` reports in, and the space every caller here works in
/// (`capture::pick_probe`'s header says it outright: *"`viewport_to_world` works in logical units
/// and a Retina capture is 2× the logical window"*). `From<Handle<Image>>` builds an
/// `ImageRenderTarget` with `scale_factor: 1.0`, and this backdrop is sized in **physical** px — so
/// the moment the world camera was retargeted at it, its logical viewport became the PHYSICAL size
/// and every conversion silently gained a factor of the display's scale.
///
/// On a 2× display that aims every world pick ray at a quarter of the screen: the ray for a cursor
/// at the centre goes to the upper-left quadrant. It hit the whole client at once — the GameObject
/// pick (the cog that "barely appears" and the right-click that "does nothing half the time" —
/// B169's fourth half), the unit pick and its occlusion ray, and the `world_to_viewport` side
/// (chat bubbles, `target::scan`). Stamping the window's real scale factor restores the
/// pre-composite semantics for all of them at once, which is why it lives here and not as a
/// conversion at each of the eight call sites.
///
/// Re-stamped when the factor changes, not only on `Added`: dragging the window between a Retina
/// and a non-Retina display changes it mid-session.
fn retarget_world_camera(
backdrop: Res<WorldBackdrop>,
mut cameras: Query<&mut RenderTarget, Added<WorldCamera>>,
mut backdrop: ResMut<WorldBackdrop>,
windows: Query<&Window, With<PrimaryWindow>>,
added: Query<(), Added<WorldCamera>>,
mut cameras: Query<&mut RenderTarget, With<WorldCamera>>,
) {
let scale_factor = windows
.single()
.map_or(1.0, |w| w.resolution.scale_factor());
// `!=` on the stored NAN seed is true, so the first run always stamps.
#[allow(clippy::float_cmp)]
let moved_display = scale_factor != backdrop.scale_factor;
if !moved_display && added.is_empty() {
return;
}
backdrop.scale_factor = scale_factor;
for mut current in &mut cameras {
*current = RenderTarget::Image(backdrop.image.clone().into());
*current = RenderTarget::Image(bevy::camera::ImageRenderTarget {
handle: backdrop.image.clone(),
scale_factor,
});
}
}
@@ -228,6 +271,63 @@ mod tests {
assert!(usage.contains(TextureUsages::TEXTURE_BINDING));
}
/// **The world camera's target carries the WINDOW's scale factor, not `1.0`** — the pick
/// regression of 2026-08-26 (B169's fourth half).
///
/// `From<Handle<Image>>` builds an `ImageRenderTarget` with `scale_factor: 1.0`, and this
/// backdrop is sized in PHYSICAL px. A camera whose target says "physical size, scale 1"
/// reports that physical size as its LOGICAL viewport — but every caller works in logical
/// units, because that is what `Window::cursor_position` reports in. So on a 2× display the
/// world's pick ray for a centred cursor went to the upper-left quadrant, and the cog "barely
/// appeared" while right-click "did nothing half the time". It hit all eight
/// `viewport_to_world`/`world_to_viewport` sites at once, not just the GameObject pick.
///
/// The scale factor is re-stamped when it CHANGES, not only on `Added`: dragging the window
/// from a Retina display to a non-Retina one changes it mid-session, and a stale factor is the
/// same defect with a different constant.
#[test]
fn the_world_cameras_target_carries_the_windows_scale_factor() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let window = |sf: f32| {
let mut w = Window::default();
w.resolution.set_scale_factor(sf);
w
};
app.world_mut().spawn((window(2.0), PrimaryWindow));
app.add_systems(Startup, setup_backdrop)
.add_systems(Update, retarget_world_camera);
let cam = app
.world_mut()
.spawn((WorldCamera, RenderTarget::default()))
.id();
app.update();
let stamped = |app: &App, e: Entity| match app.world().entity(e).get::<RenderTarget>() {
Some(RenderTarget::Image(t)) => t.scale_factor,
_ => panic!("the world camera must target the backdrop image"),
};
assert_eq!(
stamped(&app, cam),
2.0,
"a 2× display: the target must report the window's scale factor, or every logical \
cursor position is read as a physical one and the pick ray misses by 2×"
);
// …and it follows the window across displays.
let mut w = app.world_mut().query::<&mut Window>();
w.single_mut(app.world_mut())
.unwrap()
.resolution
.set_scale_factor(1.0);
app.update();
assert_eq!(
stamped(&app, cam),
1.0,
"moving to a 1× display re-stamps: a stale factor is the same defect, inverted"
);
}
/// Physical, not logical: the backdrop matches the swapchain 1:1 so the UI pass's sample is an
/// identity resample. A zero-size window (minimised on some platforms) must still produce a
/// legal texture rather than a device error.
+53 -13
View File
@@ -11,6 +11,40 @@ fn v3(v: Vector3d) -> [f32; 3] {
[v.x, v.y, v.z]
}
/// **Who cast it** — the caster slot of `SMSG_SPELL_START`/`SMSG_SPELL_GO`, corrected for the one
/// case where vmangos leaves that slot EMPTY.
///
/// Both packets carry a guid pair: slot 1 is *the cast item's guid when one is in play, else the
/// caster's own* (`WriteGuidHelper(data, m_CastItem)` / `else … m_caster`, `Spell.cpp:4475-4477` and
/// `4509-4511`), slot 2 is the caster. But slot 2 is written from **`m_casterUnit`**
/// (`Spell.cpp:4479`, `Spell.cpp:4513`) — and `m_casterUnit` is a `Unit*` that the GameObject
/// constructor never sets: `Spell::Spell(GameObject*, …)` initialises `m_caster(caster),
/// m_casterGo(caster)` and leaves the default `Unit* const m_casterUnit = nullptr` (`Spell.cpp:102`,
/// `Spell.h:368`) standing. `WriteGuidHelper(data, nullptr)` writes `ObjectGuid().WriteAsPacked()`
/// (`Spell.cpp:4441-4453`) — a lone zero mask byte — so **every spell a GameObject casts arrives
/// with caster guid 0**.
///
/// That is not a rare shape. `GameObject::Use` keeps `WorldObject* spellCaster = this` for a
/// `GAMEOBJECT_TYPE_SPELLCASTER` (22) and casts through the GameObject overload
/// (`GameObject.cpp`, `spellCaster->ToGameObject()` → `new Spell(pGo, …)`): the Priest's
/// **Lightwell** (GO 181102, spell 7001 "Lightwell Renew" on the clicker) is the canonical one, and
/// a guid the index can never hold cost that cast its whole visual body — the impact kit, its sound
/// and its effect model — because [`crate::events::SessionEvent::SpellGo`]'s consumer resolves the
/// caster before it plays anything. Slot 1 already carries the answer whenever slot 2 is empty:
/// with no cast item it IS `m_caster`, GameObject included. So an empty caster slot falls back to
/// it, which also stops the object being mistaken for a **cast item** by the `item_caster`
/// derivation below (they are equal, so nothing is derived).
///
/// A Unit caster can never reach this: `Spell::Spell(Unit*, …)` sets `m_casterUnit(caster)`
/// (`Spell.cpp:60`) and a live `Unit*` has a non-zero guid.
fn spell_caster(item_or_caster: u64, caster_slot: u64) -> u64 {
if caster_slot == 0 {
item_or_caster
} else {
caster_slot
}
}
/// Decode one server packet into zero or more [`SessionEvent`]s. Pure: no I/O, no state. Packets the
/// client doesn't model yield an empty list.
pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
@@ -166,25 +200,31 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::AiReaction { unit, reaction }]
}
ServerPacket::SpellStart(s) => vec![SessionEvent::SpellStart {
caster: s.caster,
caster: spell_caster(s.item_or_caster, s.caster),
spell_id: s.spell_id,
cast_flags: s.cast_flags,
cast_time_ms: s.cast_time_ms,
target: s.targets.unit_target,
ammo_display_id: s.ammo_display_id,
}],
ServerPacket::SpellGo(s) => vec![SessionEvent::SpellGo {
caster: s.caster,
spell_id: s.spell_id,
cast_flags: s.cast_flags,
hits: s.hits,
misses: s.misses,
target: s.targets.unit_target,
go_target: s.targets.go_target,
dest: s.targets.dest.map(|d| [d.x, d.y, d.z]),
ammo_display_id: s.ammo_display_id,
item_caster: (s.item_or_caster != s.caster).then_some(s.item_or_caster),
}],
ServerPacket::SpellGo(s) => {
let caster = spell_caster(s.item_or_caster, s.caster);
vec![SessionEvent::SpellGo {
caster,
spell_id: s.spell_id,
cast_flags: s.cast_flags,
hits: s.hits,
misses: s.misses,
target: s.targets.unit_target,
go_target: s.targets.go_target,
dest: s.targets.dest.map(|d| [d.x, d.y, d.z]),
ammo_display_id: s.ammo_display_id,
// Against the RESOLVED caster, not the raw slot: a GameObject's cast puts its own
// guid in slot 1, and comparing that with the empty slot 2 would label the object
// an item and route the cast down the item-use lane.
item_caster: (s.item_or_caster != caster).then_some(s.item_or_caster),
}]
}
ServerPacket::SpellChainTargets(c) => vec![SessionEvent::SpellChainTargets {
caster: c.caster,
spell_id: c.spell_id,
@@ -170,8 +170,9 @@ fn read_ammo(r: &mut impl Read) -> io::Result<u32> {
/// One decoded `SMSG_SPELL_START` — a non-triggered cast began, instants included (`cast_time_ms ==
/// 0` — the precast trigger, decision 0099 phase 1). VERIFIED vmangos `Spell::SendSpellStart`
/// (`Spell.cpp:4468-4503`): `item_or_caster` pguid (the cast item's guid when one is in play, else
/// the caster's own — `WriteGuidHelper`, `Spell.cpp:4453-4466`) · `caster` pguid (`m_casterUnit`,
/// always the casting Unit) · `u32 spellId` · `u16 castFlags` (always `CAST_FLAG_UNKNOWN2` 0x2, +
/// the caster's own — `WriteGuidHelper`, `Spell.cpp:4453-4466`) · `caster` pguid (`m_casterUnit` —
/// **empty, i.e. guid 0, whenever the caster is a GameObject**, which never sets that member;
/// [`crate::events`]' `spell_caster` is what resolves the pair) · `u32 spellId` · `u16 castFlags` (always `CAST_FLAG_UNKNOWN2` 0x2, +
/// `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u32` remaining cast-time ms (`m_timer`) ·
/// [`SpellCastTargets`] · the ammo block iff `castFlags & 0x20`.
#[derive(Debug, Clone, PartialEq)]
@@ -214,7 +215,8 @@ const SPELL_MISS_REFLECT: u8 = 11;
/// One decoded `SMSG_SPELL_GO` — the cast launched. VERIFIED vmangos `Spell::SendSpellGo`
/// (`Spell.cpp:4505-4538`) + its target-list writer `WriteSpellGoTargets` (`Spell.cpp:4608-4659`):
/// the same guid pair + spellId as [`SpellStart`] · `u16 castFlags` (always `CAST_FLAG_UNKNOWN9`
/// the same guid pair + spellId as [`SpellStart`] (caster slot 2 empty for a GameObject caster —
/// see there) · `u16 castFlags` (always `CAST_FLAG_UNKNOWN9`
/// 0x100, + `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u8` hit count + that many **raw** (unpacked)
/// `u64` hit guids · `u8` miss count + that many `{u64 guid, u8 SpellMissInfo, u8 reflectResult iff
/// the reason is `SPELL_MISS_REFLECT`}` · [`SpellCastTargets`] · the ammo block iff `castFlags &
+50
View File
@@ -500,6 +500,56 @@ fn spell_visual_wire_golden() {
other => panic!("ground spell go event, got {other:?}"),
}
// SMSG_SPELL_GO for a **GameObject caster** — the Priest's Lightwell (GO entry 181102) casting
// spell 7001 "Lightwell Renew" on the player who clicked it. `GameObject::Use` keeps
// `spellCaster = this` for `GAMEOBJECT_TYPE_SPELLCASTER` (22) and builds the GameObject
// overload, whose ctor leaves `Unit* const m_casterUnit = nullptr` standing (`Spell.cpp:102`,
// `Spell.h:368`) — so `SendSpellGo`'s slot-2 write `WriteGuidHelper(data, m_casterUnit)`
// (`Spell.cpp:4513`) emits `ObjectGuid().WriteAsPacked()`: a lone zero mask byte, guid 0.
// Slot 1 still carries the object (no cast item → `m_caster`). The event layer must resolve the
// pair, or the cast has no caster the world index can ever hold and loses its whole visual body.
const LIGHTWELL: u64 = 0xF110_02C3_6E00_0123; // HIGHGUID_GAMEOBJECT | entry 181102 << 24 | 0x123
let body = hx(concat!(
"fb23016ec30210f1", // item_or_caster pguid: the GameObject (mask 0xfb — byte 2 is zero)
"00", // caster pguid: EMPTY — vmangos wrote a null `m_casterUnit`
"591b0000", // spellId 7001 (Lightwell Renew)
"0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9)
"01", // hit count 1
"4500000000000000", // hit: the clicking player (raw u64)
"00", // miss count 0
"0200", // SpellCastTargets mask: TARGET_FLAG_UNIT
"0145", // …the same player, packed
));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap();
match &packet {
ServerPacket::SpellGo(g) => {
// The message layer stays a faithful decode: slot 2 really is empty on the wire.
assert_eq!((g.item_or_caster, g.caster), (LIGHTWELL, 0));
}
other => panic!("lightwell spell go, got {}", other.name()),
}
match decode(packet).pop().unwrap() {
SessionEvent::SpellGo {
caster,
spell_id,
hits,
target,
item_caster,
..
} => {
assert_eq!(
caster, LIGHTWELL,
"an empty caster slot falls back to slot 1 — the GameObject IS the caster"
);
assert_eq!((spell_id, hits, target), (7001, vec![0x45], Some(0x45)));
assert_eq!(
item_caster, None,
"the object is the caster, not a cast item — the derivation reads the RESOLVED caster"
);
}
other => panic!("lightwell spell go event, got {other:?}"),
}
// SMSG_SPELL_FAILED_OTHER: raw (unpacked) guid + spellId (vmangos `Spell::SendInterrupted`).
let body = hx(concat!("4200000000000000", "85000000"));
let packet = messages::parse_server(messages::opcode::SMSG_SPELL_FAILED_OTHER, &body).unwrap();