the bubble's pale lines, and the trigger NPC finally hides

- the pale lines through the speech bubble were its backdrop atlas bleeding across its own slices under magnification; the slices now inset, verified per row against the real backdrop bytes
- a bubble that never appears can say why: all seven silent refusals report themselves under the bubble trace
- the invisible trigger creature goes invisible at last — there is no server flag to consume; its model is authored empty, and the black slab people kept meeting was our own placeholder cube. a display that names a model now draws exactly what that model builds, including nothing
- a census probe lists which streamed entities are standing as placeholder cubes, and on which displays

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:22:57 +07:00
co-authored by Claude Fable 5
parent 76f316d040
commit 287b109773
13 changed files with 637 additions and 42 deletions
+1 -1
View File
@@ -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};
@@ -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;
@@ -0,0 +1,180 @@
//! The **unit-visual census** (`WOW_UNIT_VISUALS=<secs>[,<every>]`) — 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<n> 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::<f32>().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<VisualAttached>,
);
/// 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<UnitVisuals>,
time: ProbeClock,
names: Res<NameCache>,
body: Query<&Transform, With<SelfPlayer>>,
entities: Query<VisualQuery>,
cubes: Query<(), With<FallbackCube>>,
meshes: Query<(), With<Mesh3d>>,
) {
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<u32> = 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}");
}
}
+70 -4
View File
@@ -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<Image>,
edge: Handle<Image>,
tail: Handle<Image>,
/// 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<Image>` 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()
});
+8
View File
@@ -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=<secs>[,<every>]` 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=<secs>` 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() {
+12
View File
@@ -201,6 +201,18 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
})
}
/// 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.)
+31 -9
View File
@@ -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,
));
});
}
@@ -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"
);
}
}
@@ -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 {
+44
View File
@@ -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!();
+101
View File
@@ -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<BackdropPiece> {
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<f32> = 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<f32> = 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()),
+114 -27
View File
@@ -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 <VA>` 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<String>, 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<String>, 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::<i64>(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]
+1 -1
View File
@@ -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,