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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user