diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml index 178365a9..1e59a4ec 100644 --- a/crates/benilla-app/assets/ui/ChatFrame.xml +++ b/crates/benilla-app/assets/ui/ChatFrame.xml @@ -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. -- diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 491013b2..4fc78c43 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 9f24c3ab..6ba737d4 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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 diff --git a/crates/benilla-app/src/net/apply/spells.rs b/crates/benilla-app/src/net/apply/spells.rs index 29c394ac..af301f80 100644 --- a/crates/benilla-app/src/net/apply/spells.rs +++ b/crates/benilla-app/src/net/apply/spells.rs @@ -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", diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index 5f0f39cc..d0207765 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -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::(CHUNK).unwrap_or(-1.0) as f32; - if bottom < 0.0 { + let frac: f32 = script.eval::(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 diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index fadfa072..2dee1f2b 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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`]). /// diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index e106fc50..043533de 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -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 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 uv_clamp: vec4; // 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) -> vec3 { @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { - 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 diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index b013a9a2..4475c5bd 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -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, + /// **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, /// 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>, + /// 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, mask: Option, + 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>, [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(); diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 55fc8845..fbf9d239 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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=,[,]` — **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> = 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>> = 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("") + ), + 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) -> 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; diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index 18702395..47260422 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/world_state_tests.rs b/crates/benilla-app/src/ui_script/world_state_tests.rs index def96945..f15b3b64 100644 --- a/crates/benilla-app/src/ui_script/world_state_tests.rs +++ b/crates/benilla-app/src/ui_script/world_state_tests.rs @@ -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::("return 20 + WORLD_STATE_ROW_HEIGHT * 2 + 15") - .expect("the frame's own numbers") as f32; + .eval::("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}" diff --git a/crates/benilla-app/src/world_backdrop.rs b/crates/benilla-app/src/world_backdrop.rs index 8dfffb29..dd92b67b 100644 --- a/crates/benilla-app/src/world_backdrop.rs +++ b/crates/benilla-app/src/world_backdrop.rs @@ -69,6 +69,10 @@ pub(crate) struct WorldBackdrop { pub(crate) image: Handle, /// 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>` 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, - mut cameras: Query<&mut RenderTarget, Added>, + mut backdrop: ResMut, + windows: Query<&Window, With>, + added: Query<(), Added>, + mut cameras: Query<&mut RenderTarget, With>, ) { + 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>` 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::(); + 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::() { + 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. diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index e9eb9155..58e84942 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -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 { @@ -166,25 +200,31 @@ pub fn decode(packet: ServerPacket) -> Vec { 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, diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index b83114e9..2b72fd63 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -170,8 +170,9 @@ fn read_ammo(r: &mut impl Read) -> io::Result { /// 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 & diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index cbc1f3f8..2a69b2ba 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -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();