diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 3a4b3135..59136cc8 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -114,7 +114,7 @@ pub(crate) use probe_taxi::ProbeTaxiPlugin; pub(crate) use probes::{ fx_draw_census_plugin, EntityCensusPlugin, GroundCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin, - ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, + ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, UnitVisualsPlugin, }; use scenarios::GlueScreen; use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS}; diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 8311fb92..745b3c0c 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -34,6 +34,12 @@ pub(crate) use particle_census::ParticleCensusPlugin; mod ground_census; pub(crate) use ground_census::GroundCensusPlugin; +/// The unit-visual census — per-entity "what visual did this display actually get", which is what +/// separates a debug cube (a gap of ours) from a model that legitimately draws nothing (B13, +/// decision 1403). +mod visual_census; +pub(crate) use visual_census::UnitVisualsPlugin; + /// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype /// census — "what is resident right now, and what is it made of". mod world_census; diff --git a/crates/benilla-app/src/capture/probes/visual_census.rs b/crates/benilla-app/src/capture/probes/visual_census.rs new file mode 100644 index 00000000..e7137da6 --- /dev/null +++ b/crates/benilla-app/src/capture/probes/visual_census.rs @@ -0,0 +1,180 @@ +//! The **unit-visual census** (`WOW_UNIT_VISUALS=[,]`) — the instrument that closes +//! B13's symptom: *"the invisible trigger NPC displays as a black block"*. +//! +//! Every site of that bug arrived as a screenshot of a black slab, and a screenshot cannot say +//! which of the two things a slab is. Both look identical and they call for opposite fixes: +//! +//! - the entity's display named **no model we could load** — a gap of ours, and the cube is the +//! debug signal that says so (it renders black rather than its authored red only because an +//! unlit `StandardMaterial` catches no light in our scene); +//! - the entity's display named a model which **draws nothing** — how an invisible trigger +//! creature hides in the real client, and nothing should be drawn at all (decision 1403). +//! +//! One line per streamed entity says which, without an eye: +//! +//! ```text +//! UVIS 0xF130003A7200013B Unit display=13069 d=6.4 cube=1 meshes=0 Zandalarian Event Generator +//! ``` +//! +//! - **`cube`** is the [`FallbackCube`] marker — literally the arm that spawned it, not a guess +//! from the picture. The headline `cubes=` count is the number that names the bug. +//! - **`meshes`** counts the entity's spawned render children. `cube=0 meshes=0` is the *correct* +//! reading for a trigger creature: attached, and drawing nothing. +//! - **`pending`** marks an entity whose visual has not been built yet (a model still streaming) — +//! never to be confused with one that built nothing, which is the distinction the census exists +//! to keep. +//! +//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot: +//! +//! ```text +//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe WOW_NOSOUND=1 \ +//! WOW_PROBE_CHAT=".go xyz -11847.0 1280.9 3.2 0" \ +//! WOW_UNIT_VISUALS="45,15" WOW_PROBE_EXIT_AT=90 cargo run -q -p benilla +//! ``` +//! +//! The repeat form is the load-race half: a census at 45 s and again at 60 s says whether a `cube` +//! is a *verdict* or just an entity whose model had not landed yet — the same transient-vs-ratchet +//! separation `WOW_GROUND_CENSUS` makes for the under-floor report. + +use benilla_assets::coords::bevy_to_wow; +use benilla_protocol::EntityKind; +use bevy::prelude::*; + +use super::ProbeClock; +use crate::entities::{FallbackCube, VisualAttached}; +use crate::names::NameCache; +use crate::net::{Guid, NetEntity, SelfPlayer}; + +/// How far from the body the census looks, in yards. Comfortably past the server's own creature +/// visibility radius for a spot the operator has just `.go`ne to, so "nothing found" means the +/// scene is empty rather than the window being tight. +const DEFAULT_RADIUS: f32 = 120.0; + +pub(crate) struct UnitVisualsPlugin; + +impl Plugin for UnitVisualsPlugin { + fn build(&self, app: &mut App) { + let raw = std::env::var("WOW_UNIT_VISUALS").unwrap_or_default(); + let mut parts = raw + .split(',') + .map(|s| s.trim().parse::().unwrap_or(0.0)); + let at = parts.next().filter(|v| *v > 0.0).unwrap_or(45.0); + let every = parts.next().unwrap_or(0.0); + let radius = std::env::var("WOW_UNIT_VISUALS_RADIUS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_RADIUS); + app.insert_resource(UnitVisuals { + next: at, + every, + radius, + }) + .add_systems(Update, fire_unit_visuals); + } +} + +/// [`UnitVisualsPlugin`] state: when the next census fires, how often after that (`0` = once), and +/// how far from the body to look. +#[derive(Resource)] +struct UnitVisuals { + next: f32, + every: f32, + radius: f32, +} + +/// What the census reads per entity: identity, kind + display, pose, and whether its visual has +/// been built yet. +type VisualQuery = ( + &'static Guid, + &'static NetEntity, + &'static Transform, + Option<&'static Children>, + Has, +); + +/// One line per streamed entity within [`UnitVisuals::radius`] of the body — cubes first, then +/// everything else — under a summary line naming the count that matters. +fn fire_unit_visuals( + mut probe: ResMut, + time: ProbeClock, + names: Res, + body: Query<&Transform, With>, + entities: Query, + cubes: Query<(), With>, + meshes: Query<(), With>, +) { + let now = time.elapsed_secs(); + if probe.next <= 0.0 || now < probe.next { + return; + } + probe.next = if probe.every > 0.0 { + now + probe.every + } else { + -1.0 + }; + let Ok(body) = body.single() else { + println!("UNIT_VISUALS t={now:.1} NO BODY — not in world, nothing measured"); + return; + }; + let radius2 = probe.radius * probe.radius; + + // `(is_cube, sort key, line)`. Cubes sort to the top: they are the finding, and a busy city + // census is long. + let mut rows: Vec<(bool, i64, String)> = Vec::new(); + let (mut cube_n, mut blank_n, mut pending_n) = (0u32, 0u32, 0u32); + let mut cube_displays: Vec = Vec::new(); + for (guid, net, t, children, attached) in &entities { + if matches!(net.kind, EntityKind::DynamicObject | EntityKind::Other) + || t.translation.distance_squared(body.translation) > radius2 + { + continue; + } + let kids = children.map(|c| c.iter()).into_iter().flatten(); + let (mut cube, mut mesh_n) = (false, 0u32); + for kid in kids { + cube |= cubes.contains(kid); + mesh_n += u32::from(meshes.contains(kid)); + } + // A cube IS a mesh child; count it as the cube it is so `meshes` reads as real geometry. + mesh_n = mesh_n.saturating_sub(u32::from(cube)); + let display = net.display_id.unwrap_or(0); + if !attached { + pending_n += 1; + } else if cube { + cube_n += 1; + if !cube_displays.contains(&display) { + cube_displays.push(display); + } + } else if mesh_n == 0 { + blank_n += 1; + } + let dist = t.translation.distance(body.translation); + rows.push(( + cube, + (dist * 100.0) as i64, + format!( + "UVIS {:#018x} {:<12} display={display:<6} d={dist:6.1} cube={} meshes={mesh_n:<3} \ + {:<8} {}", + guid.0, + format!("{:?}", net.kind), + u8::from(cube), + if attached { "attached" } else { "PENDING" }, + names.peek(guid.0).unwrap_or("?"), + ), + )); + } + rows.sort_by_key(|(cube, dist, _)| (!*cube, *dist)); + let at = bevy_to_wow(body.translation); + println!( + "UNIT_VISUALS t={now:.1} entities={} cubes={cube_n} cube_displays={cube_displays:?} \ + no-geometry={blank_n} pending={pending_n} radius={:.0} body=({:.2},{:.2},{:.2})", + rows.len(), + probe.radius, + at[0], + at[1], + at[2], + ); + for (_, _, line) in &rows { + println!("{line}"); + } +} diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 9be467fc..6a84c944 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -57,7 +57,7 @@ use bevy::ecs::entity::EntityHashSet; use bevy::prelude::*; use benilla_ui::layout::Rect as GxRect; -use benilla_ui::script::{pieces, Backdrop, Insets}; +use benilla_ui::script::{inset_atlas_bleed, pieces, Backdrop, Insets}; use benilla_ui::script::{JustifyH, JustifyV, Outline}; use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback}; @@ -204,6 +204,15 @@ fn sanitize(text: &str) -> String { out } +/// The bleed inset for one border piece, `Vec2::ZERO` texels meaning "art size unknown, leave the +/// UVs alone" (a bare test app with no patch chain). +fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] { + if texels.x <= 0.0 || texels.y <= 0.0 { + return uvs; + } + inset_atlas_bleed(uvs, texels.x, texels.y) +} + /// The frame's bottom-left origin for a projected seat: BOTTOM-CENTER on the seat, then both /// coordinates snapped onto the **device** pixel grid ([`device_snap`], the plate's law). /// @@ -245,6 +254,18 @@ impl BubbleQueue { text: &str, ) { if sender_guid == 0 || !bubble_cvar(kind, cfg).unwrap_or(false) { + if benilla_assets::trace::enabled_for("bub") { + let why = match (sender_guid, bubble_cvar(kind, cfg)) { + (0, _) => "senderless".to_string(), + (_, None) => format!("{kind:?}-never-bubbles"), + (_, Some(false)) => format!("{kind:?}-cvar-off"), + _ => unreachable!("the guard above admits nothing else"), + }; + benilla_assets::trace::line( + "bub", + &format!("refuse guid={sender_guid:#x} push:{why}"), + ); + } return; } self.0.push((sender_guid, kind, text.to_string())); @@ -282,6 +303,10 @@ struct BubbleArt { bg: Handle, edge: Handle, tail: Handle, + /// The edge atlas's size in texels, for the half-texel bleed inset + /// ([`benilla_ui::script::backdrop::inset_atlas_bleed`] — 1402). Stamped at load because the + /// draw has no `Assets` and this never changes. + edge_texels: Vec2, } fn load_bubble_art( @@ -299,7 +324,16 @@ fn load_bubble_art( warn!("chat_bubble: bubble art missing from the patch chain — bubbles will not draw"); return; }; - commands.insert_resource(BubbleArt { bg, edge, tail }); + let edge_texels = images.get(&edge).map_or(Vec2::ZERO, |i| { + let s = i.texture_descriptor.size; + Vec2::new(s.width as f32, s.height as f32) + }); + commands.insert_resource(BubbleArt { + bg, + edge, + tail, + edge_texels, + }); } /// Spawn, tick, and draw, every frame: drain the queue through the spawn gate (`0x608ac0`), @@ -342,13 +376,25 @@ fn drive_bubbles( let find = |guid: u64| units.iter().find(|(_, g, _)| g.0 == guid); // ── The spawn/replace gate (`0x608ac0`) ───────────────────────────────────────────────── + // Every arm below reports its REFUSAL under the `bub` tag. A bubble that never appears used to + // be a silent five-way question — no text, no unit, a plate in the way, no local player, out of + // range — with nothing in any log to separate them, and the draw trace beside it says nothing + // because the draw never runs. One line per refusal is the difference between reading the + // answer and bisecting for it. + let refuse = |why: &str, guid: u64| { + if benilla_assets::trace::enabled_for("bub") { + benilla_assets::trace::line("bub", &format!("refuse guid={guid:#x} {why}")); + } + }; for (guid, kind, raw) in queue.0.drain(..) { let text = sanitize(&raw); let words = word_count(&text); if words == 0 { + refuse("empty-text", guid); continue; // empty text → duration 0 → no bubble } let Some((entity, _, tf)) = find(guid) else { + refuse("sender-not-a-live-unit", guid); continue; // sender not resolvable to a live unit → NO bubble }; // An active V-plate blocks bubble creation (`0x608adc` — the mutual exclusion, @@ -356,12 +402,16 @@ fn drive_bubbles( // OFF (0599's other half), so friendly/party bubbles have room; a plated hostile // yelling shows no bubble, exactly like the reference with plates toggled on. if vplates.0.contains(&entity) { + refuse("v-plate-on-speaker", guid); continue; } let Some(self_tf) = self_tf else { + refuse("no-local-player", guid); continue; // the local player must resolve }; - if (tf.translation - self_tf.translation).length_squared() > MAX_DIST_SQ { + let d2 = (tf.translation - self_tf.translation).length_squared(); + if d2 > MAX_DIST_SQ { + refuse(&format!("out-of-range dist={:.1}yd", d2.sqrt()), guid); continue; // 20 yd at spawn } let is_self = self_guid.0 == Some(guid); @@ -441,10 +491,17 @@ fn drive_bubbles( ); let seat_world = Vec3::new(tf.translation.x, anchor.y + LIFT, tf.translation.z); let cam_tf = GlobalTransform::from(*cam_pose); + // The SEVENTH silent cause, and the one that cost the most to find: a seat behind the + // camera fails to project and the bubble draws nothing — alive, ticking, invisible, and + // (before this) invisible to the trace too, because every `bub` line is written by the + // draw. A probe whose camera had been restored pitched into the ground therefore read as + // "bubbles are broken" for six runs (1402). Reported like the spawn gate's refusals. let Ok(seat) = cam.world_to_viewport(&cam_tf, seat_world) else { + refuse("not-on-screen (behind the camera)", *guid); continue; }; let Some(viewport) = cam.logical_viewport_size() else { + refuse("no-viewport", *guid); continue; }; draw_bubble( @@ -555,6 +612,15 @@ fn draw_bubble( p.corners[2][0], -p.corners[2][1], ); + // The border pieces share one 256×32 atlas; without the half-texel inset, bilinear at each + // piece's own edge blends in the neighbour's first column — and the column beside the TOP + // slice is WHITE at alpha 0, which is the pale line that ran through the bubble (1402). The + // bg is its own texture and keeps its UVs. + let uvs = if p.is_bg { + p.uvs + } else { + pieces_inset(p.uvs, art.edge_texels) + }; quads.overlays.push(UiQuad { rect, z_key: if p.is_bg { Z_BG } else { Z_EDGE }, @@ -563,7 +629,7 @@ fn draw_bubble( } else { art.edge.clone() }), - uv: UvRect::from_corners(p.uvs), + uv: UvRect::from_corners(uvs), color: [1.0, 1.0, 1.0, alpha], ..default() }); diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 936ff12b..0153ae8a 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -186,6 +186,14 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_GROUND_CENSUS").is_ok() { app.add_plugins(crate::capture::GroundCensusPlugin); } + // The unit-visual census: `WOW_UNIT_VISUALS=[,]` prints one line per + // streamed entity near the body — whether it got a debug cube, real geometry, or + // nothing at all. The instrument B13 was missing: a black slab in a screenshot cannot + // say whether the display named no model (our gap) or named one that draws nothing + // (an invisible trigger creature — see `capture::UnitVisualsPlugin`, decision 1403). + if std::env::var("WOW_UNIT_VISUALS").is_ok() { + app.add_plugins(crate::capture::UnitVisualsPlugin); + } // The entity census: `WOW_ENTITY_CENSUS=` prints per-archetype entity counts once — // what the resident entity count is made of (see `capture::EntityCensusPlugin`). if std::env::var("WOW_ENTITY_CENSUS").is_ok() { diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 7aab02ce..8174555a 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -201,6 +201,18 @@ pub(crate) fn overhead_anchor( }) } +/// The child mesh spawned by the fallback-cube arm of [`attach::attach_entity_visuals`] — "this +/// entity's display named no model we could load". +/// +/// A marker, so the condition is **countable** rather than eyeballed: `WOW_UNIT_VISUALS` +/// ([`crate::capture::probes::UnitVisualsPlugin`]) reports how many streamed entities are standing +/// as cubes, and on which displays. Before decision 1403 a cube also stood for every invisible +/// trigger creature, which is what B13 saw as a black slab — an unlit `StandardMaterial` catches no +/// light in our scene, so the "red" NPC box renders pure black. The census is how that stays +/// visible if the gate ever regresses. +#[derive(Component)] +pub(crate) struct FallbackCube; + /// Shared fallback cube mesh + per-kind materials, used when an entity has no usable model. (No /// GameObject color: GameObjects render their model or nothing — a model-less GameObject is an /// effect/trigger that's invisible in the real client.) diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index a55cd7e9..317f984f 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -301,7 +301,7 @@ pub(super) fn attach_entity_visuals( let worn = resolve_worn_equip(net, equipment, dm); let equip = worn.bodyslots; // A model still loading (`parts == None`) waits — leave it un-attached and retry next frame - // rather than flash a cube we'd swap out. A built-but-empty model falls through to a cube. + // rather than flash a cube we'd swap out. A built-but-empty model draws nothing. let model = match dm { Some(d) => match &d.parts { None => continue, @@ -310,13 +310,20 @@ pub(super) fn attach_entity_visuals( }, None => None, }; + // **Did the display name a model file at all?** An empty `parts` list means two opposite + // things, and the cube below may only answer one of them (decision 1403): a display that + // resolved to a model which built zero batches has been *answered* — the model draws + // nothing — while a display that resolved to no model at all is a gap of ours. + let named_a_model = dm.is_some_and(DisplayModel::names_a_model); // Invisible interaction-zone GameObjects (the forge, fishing-bobber zone, aura generators, …) - // carry a *transparent* placeholder M2: the real client's mesh gate is **type-independent** — - // it draws any loaded model and the per-batch zero-alpha cull skips the transparent geometry + // carry a *transparent* placeholder M2, and invisible **trigger creatures** an empty or + // constant-zero-alpha one: the real client's mesh gate is **type-independent** — it draws + // any loaded model and the per-batch zero-alpha cull skips the transparent geometry // (decision 0024, superseding 0023's wrong marker-type gate; verified wow-re go-render-gate). - // Our M2 alpha cull already reduces those models to zero submeshes, so `model` is `None` here - // and they render nothing — no GameObject-type special-case needed. + // Our M2 alpha cull already reduces those models to zero submeshes, so `model` is `None` + // here and they render nothing — neither kind needs a special case, and `named_a_model` + // above is what keeps the unit arm's debug cube off them (1403). if let Some(parts) = model { // ── Mounts (decision 0441): a mounted unit is TWO skeletons. The mount is a child // entity carrying a plain creature `NetEntity` — this very system builds it like any @@ -876,11 +883,23 @@ pub(super) fn attach_entity_visuals( } } } else { - // Cube fallback: other players (cyan, slim block) and NPCs (red, person-box) without a usable - // model. A model-less GameObject renders *nothing* — it's an effect-only/invisible/trigger - // object (all particle-only in the real client), so a floating cube would be noise. The cube - // origin is centered, so a child offset lifts it onto the ground. + // Cube fallback: other players (cyan, slim block) and NPCs (red, person-box) whose display + // named **no model to load** — the debug signal for a gap of ours. The cube origin is + // centered, so a child offset lifts it onto the ground. + // + // A display that DID name a model renders exactly what that model built — including + // nothing (decision 1403, bug B13). The reference's mesh gate is type-independent: it + // draws any loaded model and the per-batch zero-alpha cull skips what is transparent + // (decision 0024, verified wow-re `go-render-gate`), so a model with no surviving batch + // submits no geometry and the unit is invisible. That is the whole of how a **trigger + // creature** hides — `Creature\InvisibleStalker\InvisibleStalker.m2` ships with + // `nVertices == 0`, and its `NoName` sibling's one batch carries a constant-zero + // transparency track — no unit flag is consumed anywhere. Cubing them put a black slab + // over 154 vmangos templates on 8 displays (the Scarab Wall's Anachronos trigger, + // Yojamba's Zandalarian Event Generator). It was only ever the *unit* arm that got this + // wrong; the GameObject arm below has drawn nothing since 0024. let fallback = match net.kind { + _ if named_a_model => None, EntityKind::Player => { Some((assets.player_mat.clone(), assets.player_mesh.clone(), 1.0)) } @@ -918,6 +937,9 @@ pub(super) fn attach_entity_visuals( // its shape, which it must SAY (decision 0929): the picker requires pick // geometry rather than inferring a box from its absence. benilla_world::interact::PickBox, + // What makes "we are standing a cube here" a number a probe can read + // (`WOW_UNIT_VISUALS`) rather than something only an eye finds — 1403. + super::FallbackCube, )); }); } diff --git a/crates/benilla-app/src/entities/display.rs b/crates/benilla-app/src/entities/display.rs index 70dfaf8d..23d5788a 100644 --- a/crates/benilla-app/src/entities/display.rs +++ b/crates/benilla-app/src/entities/display.rs @@ -256,6 +256,18 @@ impl DisplayModel { .as_ref() .is_some_and(|p| p.iter().any(|part| part.welded_billboard)) } + + /// Does this display name a model FILE at all? The line between **"the model's own answer is + /// draw nothing"** and **"we could not resolve one"** — which are the same empty `parts` list + /// and must never be treated alike (decision 1403). + /// + /// `true` once a display resolved through its catalog to a path we handed the asset server; + /// `false` only for [`empty_display`] — a display id absent from `CreatureDisplayInfo` / + /// `GameObjectDisplayInfo`, a zero-scale row, or a GameObject display naming no path. That + /// second set is *our* gap, and the debug cube is how a session sees it. + pub(super) fn names_a_model(&self) -> bool { + !matches!(self.handle, ModelHandle::None) + } } /// Resolve a creature display id to a [`DisplayModel`]: load its M2 (no skins — the slots are filled at @@ -625,3 +637,48 @@ fn model_dir(model_path: &str) -> &str { .map(|(dir, _)| dir) .unwrap_or("") } + +#[cfg(test)] +mod tests { + use super::*; + + /// The line the attach path's debug cube hangs on (decision 1403, bug B13). Both sides of it + /// carry the SAME empty `parts` list once built, so the handle is the only thing that can tell + /// them apart: `empty_display` is a display that named no model — our gap, worth a cube — + /// while a display holding a real handle has been answered by its model, even when the answer + /// is "draw nothing" (the invisible trigger creatures). + #[test] + fn only_a_display_with_no_model_file_earns_the_cube() { + assert!( + !empty_display().names_a_model(), + "a display that resolved to no model must stay cube-eligible" + ); + assert!( + !DisplayModel { + handle: ModelHandle::None, + parts: Some(Vec::new()), + ..empty_shell() + } + .names_a_model(), + "the handle decides, not the parts" + ); + assert!( + DisplayModel { + handle: ModelHandle::M2(Handle::default()), + // Built, and it drew nothing — `InvisibleStalker.m2`'s zero batches. Not a cube. + parts: Some(Vec::new()), + ..empty_shell() + } + .names_a_model(), + "a display that named an M2 is answered by that model, empty parts included" + ); + assert!( + DisplayModel { + handle: ModelHandle::Wmo(Handle::default()), + ..empty_shell() + } + .names_a_model(), + "a WMO display names a model too" + ); + } +} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 153d6dab..38411aa0 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1049,6 +1049,18 @@ fn convert_entry( }); // No texture (missing BLP) ⇒ draw nothing rather than a phantom solid quad. let Some(handle) = handle else { return }; + // The 8 border pieces share one atlas, so bilinear at a piece's own edge blends in the + // NEIGHBOURING piece's first column unless the UVs are pulled half a texel inward + // (1402). `tile` is exactly the border-piece predicate here — every one of the 8 sets + // it, and a stretched bg (the only `tile: false` piece) is a whole texture, not a + // slice of one, so it is left alone. + let uvs = match images.get(&handle) { + Some(img) if tile => { + let sz = img.texture_descriptor.size; + benilla_ui::script::inset_atlas_bleed(uvs, sz.width as f32, sz.height as f32) + } + _ => uvs, + }; let mut color = color; color[3] *= eq.alpha; out.push(UiQuad { diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index a8a27c4d..98359352 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -82,6 +82,50 @@ fn culls_constant_zero_alpha_m2_batches() { assert_eq!(lamppost.len(), 5, "a normal prop keeps all its batches"); } +#[test] +fn trigger_creature_models_carry_no_render_geometry() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // How an invisible **trigger creature** hides in the real client (decision 1403, bug B13): its + // model draws nothing. No unit flag, no DBC column — a swept census of all 411 shipped + // `CreatureModelData` paths found exactly these three, by two different routes: + // + // InvisibleStalker — `nVertices == 0`: no geometry authored at all (80 bones, + // 135 animations, and not one vertex). + // InvisibleStalkerNoName — one 4-vertex batch whose transparency track is a constant + // `0.000`, so the verified zero-alpha cull takes it. + // Creature_SpellPortal — `nVertices == 0`, like the stalker. + // + // Between them they back 8 `CreatureDisplayInfo` rows and 154 vmangos creature templates — the + // Scarab Wall's "Anachronos Quest Trigger Invisible" and Yojamba Isle's "Zandalarian Event + // Generator" among them. If any of these ever comes back non-empty, the attach path stops + // being able to tell "this model draws nothing" from "we have no model" and the debug cube + // returns as a black slab over all 154. + for path in [ + "creature/invisiblestalker/invisiblestalker.m2", + "creature/invisiblestalker/invisiblestalkernoname.m2", + "creature/spells/creature_spellportal.m2", + ] { + let mesh = benilla_formats::load_m2_mesh(&mut chain, path) + .unwrap_or_else(|e| panic!("mesh {path}: {e:#}")); + assert!( + mesh.is_empty(), + "{path} is a trigger creature's model and must build no render geometry, got {} batches", + mesh.len() + ); + } + + // Control — an ordinary creature on the same path keeps its geometry, so "empty" is a property + // of these three models and not of the creature lane. + let chicken = benilla_formats::load_m2_mesh(&mut chain, "creature/chicken/chicken.m2") + .expect("mesh chicken"); + assert!( + !chicken.is_empty(), + "an ordinary creature model keeps its batches" + ); +} + #[test] fn suppresses_white1_invisible_trap_placeholder() { let data = benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index b3f8fffd..8bd8b69b 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -96,6 +96,52 @@ pub struct BackdropPiece { const SLICE: f32 = 0.125; +/// Pull a piece's UV rect half a texel inward on every axis whose range is **bounded** — i.e. does +/// not tile — so bilinear magnification cannot reach across an atlas slice boundary. +/// +/// The 8 border pieces live side by side in ONE texture, each occupying a [`SLICE`] of `u`, and the +/// sampler is Linear+Repeat. A quad whose `u` ends exactly on a slice boundary samples, at its own +/// edge, precisely the seam between two texels — so bilinear returns a 50/50 blend of this slice's +/// last column and the **neighbouring piece's first**. Half a texel of inset moves that edge sample +/// onto the texel's centre instead, which is the whole fix. +/// +/// It is invisible at the reference's own 1024×768, where the 32-texel slice draws into a 16 px +/// border and the bleed is subpixel. We draw the same slice into ~40 device px at 1440p — a 1.25× +/// **magnification**, which gives the boundary texel a visible band of its own. In this atlas +/// (`ChatBubble-Backdrop`, 256×32) the column next to the TOP slice is `(255,251,255)` at alpha 0, +/// so what leaked in was **white**: the pale horizontal line the director reported through the +/// speech bubble (1402), with a fainter twin under it from the BOTTOM slice's own boundary. +/// +/// The bounded-axis test is what keeps this from breaking the tiling it sits next to: `u` always +/// spans one slice and is always inset, while an edge strip's `v` runs `[0, run]` and only gets the +/// inset when `run ≤ 1`. A strip that genuinely repeats keeps its exact period — insetting a tiling +/// axis would make every period land a fraction short and walk the art along the run. +#[must_use] +pub fn inset_atlas_bleed(uvs: [[f32; 2]; 4], tex_w: f32, tex_h: f32) -> [[f32; 2]; 4] { + let mut out = uvs; + for (axis, tex) in [(0usize, tex_w), (1usize, tex_h)] { + if tex < 2.0 { + continue; // a 1-texel axis has no interior to inset toward + } + let (lo, hi) = uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| { + (lo.min(c[axis]), hi.max(c[axis])) + }); + // A tiling axis is left exactly alone (see above); `1.0` is one whole period, which is + // bounded and does get the inset — that is the corner pieces, whose `v` is `[0,1]`. + if hi - lo > 1.0 + f32::EPSILON { + continue; + } + let half = 0.5 / tex; + if hi - lo <= 2.0 * half { + continue; // degenerate: the inset would invert the range + } + for c in &mut out { + c[axis] += if c[axis] <= lo { half } else { -half }; + } + } + out +} + /// Axis-aligned corners `[TL, TR, BR, BL]` (y-up) for a piece spanning `x∈[x0,x1]`, `y∈[y0,y1]` /// (`y1` = top). The bg overrides its own corners to reproduce the BR-inset bug. fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] { @@ -236,6 +282,61 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec { mod tests { use super::*; + /// The bleed inset moves each BOUNDED axis's edge sample onto a texel CENTRE, and leaves a + /// tiling axis bit-exact. Pinned against the real `ChatBubble-Backdrop` geometry (256×32, eight + /// 32-texel slices) because that is the atlas whose white alpha-0 column put a line through the + /// speech bubble (1402). + #[test] + fn the_bleed_inset_lands_on_texel_centres_and_spares_tiling() { + const W: f32 = 256.0; + const H: f32 = 32.0; + let half_u = 0.5 / W; + + // A CORNER: u = one slice, v = [0,1]. Both axes are bounded, so both inset. + let corner = [[0.5, 0.0], [0.625, 0.0], [0.625, 1.0], [0.5, 1.0]]; + let got = inset_atlas_bleed(corner, W, H); + // In texel coordinates the edges now sit on 128.5 / 159.5 — the centres of the first and + // last texels of the slice, which is exactly what stops bilinear reaching texel 127. + let us: Vec = got.iter().map(|c| c[0] * W).collect(); + assert!((us[0] - 128.5).abs() < 1e-3, "left u at {} texels", us[0]); + assert!((us[1] - 159.5).abs() < 1e-3, "right u at {} texels", us[1]); + let vs: Vec = got.iter().map(|c| c[1] * H).collect(); + assert!((vs[0] - 0.5).abs() < 1e-3, "top v at {} texels", vs[0]); + assert!((vs[2] - 31.5).abs() < 1e-3, "bottom v at {} texels", vs[2]); + + // A TILING edge strip: u = one slice (bounded → inset), v = [0, 8] (tiling → untouched). + // Touching v would make every period land a fraction short and walk the art along the run. + let strip = [[0.25, 8.0], [0.25, 0.0], [0.375, 0.0], [0.375, 8.0]]; + let got = inset_atlas_bleed(strip, W, H); + for (g, s) in got.iter().zip(strip.iter()) { + assert_eq!(g[1], s[1], "the tiling axis must be bit-exact"); + } + assert!((got[0][0] - (0.25 + half_u)).abs() < 1e-6); + assert!((got[2][0] - (0.375 - half_u)).abs() < 1e-6); + + // The inset NEVER crosses a slice boundary — the property the whole thing exists for. + for i in 0u8..8 { + let (u0, u1) = (f32::from(i) * SLICE, f32::from(i + 1) * SLICE); + let piece = [[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]]; + for c in inset_atlas_bleed(piece, W, H) { + assert!( + c[0] > u0 && c[0] < u1, + "slice {i}: u {} escaped ({u0},{u1})", + c[0] + ); + } + } + + // Degenerate inputs are identities, not panics or inversions. + assert_eq!(inset_atlas_bleed(corner, 0.0, 0.0), corner); + let sliver = [[0.0, 0.0], [0.001, 0.0], [0.001, 1.0], [0.0, 1.0]]; + assert_eq!( + inset_atlas_bleed(sliver, W, H)[0][0], + 0.0, + "too thin to inset" + ); + } + fn tooltip_backdrop(edge_size: f32, inset: f32) -> Backdrop { Backdrop { bg_file: Some("Interface\\Tooltips\\UI-Tooltip-Background".into()), diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 35db7004..b634109c 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -48,7 +48,7 @@ use mlua::{Lua, Value}; -use super::Model; +use super::{binding_abi, Model}; /// The 1.12 weapon-subclass → `SkillLine.dbc` id table, transcribed from vmangos /// `ItemPrototype::GetProficiencySkill`'s `item_weapon_skills` (`Objects/Item.cpp:700-707`; @@ -503,6 +503,30 @@ impl Model { } } +/// The four reference `.data` literals the two indexed stat bindings raise with — read out of the +/// shipped 5875 image, verbatim, and NOT paraphrased (the house rule `GetInventorySlotInfo` sets: +/// an addon may compare or key on the message text). +/// +/// **Each binding has TWO raises with different strings, and the string pool interleaves them so +/// that "the nearest `Usage:`" picks the WRONG binding's.** The layout is +/// `Usage: UnitResistance…` (`0x8510fc`) · `Invalid resistance index…` (`0x85112c`) · +/// `Usage: UnitStat…` (`0x851158`) · `Invalid stat index…` (`0x85117c`) — so the `Usage:` two +/// padding bytes after `UnitResistance`'s index message belongs to `UnitStat`. Each literal is +/// fixed by the `push ` that names it (`0x5187d6`/`0x5187ed` for `UnitStat`, +/// `0x5185cb`/`0x5185e2` for `UnitResistance`), never by proximity. This is the same trap +/// `GetInventorySlotInfo`'s transcription records, one degree worse. +/// +/// The **index** arm is these two — no `Usage:` prefix, and the offending index is *not* +/// interpolated (`luaL_error` is called cdecl with exactly two dwords at all four sites: the `L` +/// and the message, no varargs, so the literal is never a format string). +const STAT_INDEX_ERROR: &str = "Invalid stat index in UnitStat"; +const RESISTANCE_INDEX_ERROR: &str = "Invalid resistance index in UnitResistance"; +/// The **argument-type** arm — a separate, earlier raise, reached only by a value that is neither +/// numeric nor a numeric string (the guards are coercion-aware: `UnitStat("player", "3")` serves +/// stat 3). [`binding_abi`] owns that contract. +const STAT_USAGE: &str = "Usage: UnitStat(\"unit\", statIndex)"; +const RESISTANCE_USAGE: &str = "Usage: UnitResistance(\"unit\", resistanceIndex)"; + /// Register the paper-doll stat/slot globals (decision 0208 §3). pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); @@ -526,17 +550,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // families differently, and the ref Lua's own asymmetry — `SetStats` subtracts, `SetResistances` // and `PaperDollFormatStat` do not — is the tell. // - // Out-of-range i (the ref never passes one) serves the absent zeros. + // **An out-of-range index RAISES; it does not answer zeros** (see [`STAT_INDEX_ERROR`]). g.set( "UnitStat", - lua.create_function(|lua, (token, i): (Option, i64)| { - Ok(with_unit_stats(lua, &token, |s| { - let Some(idx) = i.checked_sub(1).and_then(|v| usize::try_from(v).ok()) else { - return (0, 0, 0, 0); - }; - if idx >= 5 { - return (0, 0, 0, 0); - } + lua.create_function(|lua, (token, i): (Value, Value)| { + let token = binding_abi::string_arg(lua, token, STAT_USAGE)?; + let idx = binding_abi::number_arg(lua, i, STAT_USAGE)? - 1; + if !(0..5).contains(&idx) { + return Err(mlua::Error::RuntimeError(STAT_INDEX_ERROR.into())); + } + let idx = idx as usize; + Ok(with_unit_stats(lua, &Some(token), |s| { let (raw, pos, neg) = (s.stats[idx], s.stat_pos[idx], s.stat_neg[idx]); ( i64::from(raw), @@ -552,13 +576,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // decomposition helper `0x5efcd0` per school ([0] = armor): `base = raw − pos − neg`, computed // BEFORE the clamp, and `resistance = max(raw, 0)`. A school cursed below zero therefore reads // a *displayed* 0 with the real (negative) total still folded out of `base`. + // Out-of-range raises too, with its own string ([`RESISTANCE_INDEX_ERROR`]). g.set( "UnitResistance", - lua.create_function(|lua, (token, school): (Option, i64)| { - Ok(with_unit_stats(lua, &token, |s| { - let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else { - return (0, 0, 0, 0); - }; + lua.create_function(|lua, (token, school): (Value, Value)| { + let token = binding_abi::string_arg(lua, token, RESISTANCE_USAGE)?; + let school = binding_abi::number_arg(lua, school, RESISTANCE_USAGE)?; + if !(0..7).contains(&school) { + return Err(mlua::Error::RuntimeError(RESISTANCE_INDEX_ERROR.into())); + } + let idx = school as usize; + Ok(with_unit_stats(lua, &Some(token), |s| { let (raw, pos, neg) = ( s.resistances[idx], s.resistance_pos[idx], @@ -958,7 +986,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { - use super::{weapon_subclass_skill, SKILL_UNARMED}; + use super::{ + weapon_subclass_skill, RESISTANCE_INDEX_ERROR, SKILL_UNARMED, STAT_INDEX_ERROR, STAT_USAGE, + }; use crate::script::{InvSlotView, UiScript, UnitCombatStats}; /// A filled snapshot exercising every field the bindings read. @@ -1073,17 +1103,60 @@ mod tests { .unwrap(), (0, 0, 0, 0) ); - // Out-of-range index: zeros, no error. + } + + /// The index arm **raises**, and the truncation that decides which side of the range an + /// argument lands on happens FIRST. Both ends converge on one raise (`0x51865a js` and + /// `0x518663 jge` → `0x5187d6`), and `_ftol` chops toward zero rather than flooring, so `1.9` + /// is a valid `1` while `0.5` and any negative are not. + #[test] + fn an_out_of_range_stat_index_raises_the_references_own_string() { + let mut s = UiScript::new().unwrap(); + s.set_player_combat_stats(Some(stats())); + for arg in ["6", "0", "-3", "0.5", "-0.5"] { + let err = s + .eval::<(i64, i64, i64, i64)>(&format!(r#"return UnitStat("player", {arg})"#)) + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains(STAT_INDEX_ERROR), + "UnitStat(\"player\", {arg}) must raise {STAT_INDEX_ERROR:?}, got {msg:?}" + ); + // The index arm carries no `Usage:` prefix, and the pool's interleaving makes the + // WRONG binding's usage line the nearest one — so assert we took neither. + assert!(!msg.contains("Usage:"), "no Usage: prefix on the index arm"); + } + // Truncation toward zero, ahead of the range test: 1.9 → 1 → stat 0, the valid answer. assert_eq!( - s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 6)"#) + s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1.9)"#) .unwrap(), - (0, 0, 0, 0) + (25, 25, 4, 0) ); + // The guard is coercion-aware — a numeric STRING is a number here. assert_eq!( - s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 0)"#) + s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", "2")"#) .unwrap(), - (0, 0, 0, 0) + (20, 20, 0, -2) ); + // Neither numeric nor a numeric string takes the OTHER raise, with the other string. + for arg in ["nil", "{}", "true", r#""abc""#] { + let msg = format!( + "{}", + s.eval::<(i64, i64, i64, i64)>(&format!(r#"return UnitStat("player", {arg})"#)) + .unwrap_err() + ); + assert!( + msg.contains(STAT_USAGE) && !msg.contains(STAT_INDEX_ERROR), + "UnitStat(\"player\", {arg}) must take the Usage arm, got {msg:?}" + ); + } + // A missing unit token fails the same way (`0x6f3510` reports NULL past the top as + // neither number nor string). + assert!(format!( + "{}", + s.eval::(r#"return UnitStat(nil, 1)"#).unwrap_err() + ) + .contains(STAT_USAGE)); } #[test] @@ -1115,17 +1188,31 @@ mod tests { .unwrap(), (130, 150, 150, 30, -10) ); - // Out of range / non-player: zeros. - assert_eq!( - s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 7)"#) - .unwrap(), - (0, 0, 0, 0) - ); + // A non-player token: zeros. (UnitArmor takes no index and so has no index raise.) assert_eq!( s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#) .unwrap(), (0, 0, 0, 0, 0) ); + // Out of range RAISES, with UnitResistance's own string — not UnitStat's, which is the + // literal sitting two padding bytes away in the reference's string pool. + for arg in ["7", "-1"] { + let msg = format!( + "{}", + s.eval::<(i64, i64, i64, i64)>(&format!( + r#"return UnitResistance("player", {arg})"# + )) + .unwrap_err() + ); + assert!( + msg.contains(RESISTANCE_INDEX_ERROR) && !msg.contains("UnitStat"), + "UnitResistance(\"player\", {arg}) must raise {RESISTANCE_INDEX_ERROR:?}, got {msg:?}" + ); + } + // School 0 is in range and is armor — the low end is inclusive, unlike UnitStat's. + assert!(s + .eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 0)"#) + .is_ok()); } #[test] diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e0272e2e..6afbf988 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -131,7 +131,7 @@ pub use action::{ActionSlot, ActionState}; pub use addon::AddOnInfo; pub use addon_message::{AddonDistribution, AddonSend}; pub use aura::{AuraState, TrackingState}; -pub use backdrop::{pieces, Backdrop, BackdropPiece, Insets}; +pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets}; pub use bank::BankState; pub use char_stats::{ weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,