the lamppost halo stops stacking, and the stance bar seats on login

- the lamppost halo stops stacking after a short-range teleport: bevy registers `add_visibility_class::<Mesh3d>` as an add hook that pushes the mesh's class id onto the entity's `VisibilityClass` list on every (re)add, nothing dedups the list, the required component survives a `Mesh3d` removal, and `check_visibility` queues an entity once per entry — so 1949's parking, which removes `Mesh3d` after thirty hidden frames and re-adds it the frame the part is shown, drew a part one more time per hide-then-show cycle; opaque parts hide it at the same depth and colour, an additive glow card adds itself to itself (×2 after the first teleport that hid the lamp for half a second, ×3 after the next), and a loading screen despawns the tiles and respawns fresh entities, which is why a far teleport and a relog never showed it; the unpark queues one dedup after the hook, the test registers bevy's own hook and requirement in a bare world and reads 2 entries without it and 1 with, and on the rig, with the away camera facing the lamp past a 177 yd far clip, a build without the dedup read `stacked_parts=278` after one hop back where the landed build reads 0 (2003, 2005); the instruments that excluded the other mechanisms stay — the inspector's glow-card line names the material's additive bit, fade/unlit flags, depth write, texture residency, tag alpha and live scale, `STACKED n` on the card and `stacked_parts=` on the probe line count the duplicated class lists, and screenshot and pick bursts now keep the rig window awake, because an occluded window recaptures one stale drawable and a day of A/Bs had read identical to the decimal
- `perf::blend_check` reads the half of a glow card's look the material cannot: per `Transparent3d` item, the pipeline descriptor's colour blend against the material's additive marker — the marker folds the radial alpha into the colour in the shader and gives the pipeline its pure (ONE, ONE) add, and 0071's wrong-pipeline class was immunised only for opaque batches; mismatches count into the perf pill as a red `blend xN` the frame they happen, ride the probe line as `blend_mismatch=`, and are tracked across frames and named — object, texture, which pipeline, PERSISTING; its own first harvest was the tracker: an additive material realized this frame is extracted, prepared and drawn this frame, and a tracker reading the `Added` event a frame late called every first draw a mismatch, so it runs after `AssetEventSystems` like bevy's own specialization check; and the hook-accumulation sweep states the class so it can be swept: bevy 0.18.1's insert path fires `on_add` only when the component was absent, `on_replace` on every outgoing value, `on_remove` on removal only — a push on `on_add` accumulates across remove→re-add, a free on `on_remove` leaks across an insert-overwrite; every hook in the bevy crates we link (the visibility class on `Mesh3d`/`Mesh2d`/`Sprite`/`Text2d`/`MeshletMesh3d`, the cluster class on every bevy light) and every removal of a hooked component in our tree was read, parking's `Mesh3d` is the only accumulating pair, the UI quad pools reuse entities by insert-over and fire no add hook, and the one asymmetric freer, `PropProbeSlot`, freed its probe slot on `on_remove` — an insert over a live slot leaked it for the session, held off by a remove-then-insert discipline at the one re-seat site; it frees on `on_replace` now (`RigSkin`'s shape), the re-seat is a plain insert, and a test overwrites a live slot and reads it freed (2005)
- the stance bar loads after everything the managed-position pass reads: the stock `ShapeshiftBar_OnLoad` shows the bar, its OnShow runs `UIParent_ManageFramePositions`, and the stock pass indexes `ReputationWatchBar` unguarded — our manifest seated the bar beside the other bar files where the reference's toc has it at l.74, after the reputation frame at l.57, so the pass raised at `UIParent.lua:1618` on every login and never reached the arm that seats the shelf: the red login error and the sliver above Defensive Stance (the shelf's plate at its XML anchors, a few pixels off its seat, healed by any bottom-bar toggle re-running the pass) were one defect from 1988, whose retired copy of the pass had guarded every read — and the three-form case 2000 could not reproduce; the bar sits at the manifest's tail right before `WorldStateFrame.xml`, whose own OnLoad runs the pass and indexes the shelf textures the stance bar declares — the reference's own reason for l.74 before l.89; a manifest test states the law with the pairs read off the pass's body, and `load_default_ui` now folds the VM's handler errors into its failures, because a raise one call below an OnLoad landed only in the VM's error list and shipped through the manifest test, the full-manifest harnesses and the smoke's WARN alike (2001)
- `UnitName` answers `UNKNOWNOBJECT`, never nil, for a resolved unit whose name is still in flight: a hunter casting Call Pet with the stable window closed got `PetStable.lua:129: attempt to concatenate a nil value`, because stock `PetStable_OnEvent` repaints on `UNIT_PET` without asking whether the frame is shown and the paint concatenates `UnitName("pet")` the instant `UnitExists("pet")` is true, while the pet's name is a `CMSG_PET_NAME_QUERY` round trip away; the reference's `UnitName 0x517020` pushes nil in exactly two cases — the `"player"` fast path (`0x517083`) over an empty name buffer, and a token that resolves to GUID 0 (`0x5170c0`) — and every other path ends in a string: `CGUnit_C::GetUnitName 0x609210` looks the pet up in `petnamecache.wdb` by `UNIT_FIELD_PETNUMBER`, re-sends the query on a miss or a stale stamp, and falls to `0x609324`, the GlobalString `UNKNOWNOBJECT` ("Unknown") read through `0x703bf0`, or the literal `"Unknown Being"` at `0x860fa4` when the global is missing or empty; the binding now reads the global out of the VM's `_G` at call time, so a translated `GlobalStrings.lua` translates this too, the stock file is untouched, and 1688's "nil for an unresolved name is faithful" is corrected — the reference paints `Unknown Level 58 Boar` into the hidden frame and the next repaint reads the name; `GameTooltip:SetUnit` still titles a pending-name unit with an empty line, a next move (2002)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:32:13 +07:00
co-authored by Claude Fable 5.1
parent a9b613c85a
commit 6b812913f4
16 changed files with 825 additions and 34 deletions
+15 -4
View File
@@ -450,10 +450,6 @@ Interface\FrameXML\PetActionBarFrame.xml
# (UI-AutoCastButton.mdx) and this engine's <Model> draws no pixels; the shine benilla draws is
# its own token texture (1383). BENILLA_ALLOW_OWN_UI=1.
PetActionBarAdapters.xml
# The bonus (stance-page) bar AND the shapeshift bar — one stock file declares both, and the
# reference's toc seats it right after PetActionBarFrame (l.73-74): the bonus buttons'
# Up/Down call PetActionButtonUp/Down, which PetActionBar.xml above defines.
Interface\FrameXML\BonusActionBarFrame.xml
# The micro-button row in the bar's right-hand recess — the reference's own file (1751;
# decision 1987), which its toc seats right after MainMenuBar (l.34-35). After MainMenuBar.xml:
# the eight buttons carry parent="MainMenuBarArtFrame", resolved by name at load (1734), and a
@@ -1057,6 +1053,21 @@ Interface\FrameXML\QuestTimerFrame.xml
Interface\FrameXML\ChatFrame.xml
Interface\FrameXML\FloatingChatFrame.xml
# The bonus (stance-page) bar AND the shapeshift bar — one stock file declares both. The
# reference's toc seats it at l.74, after PetActionBarFrame (the bonus buttons' Up/Down call
# PetActionButtonUp/Down). It sits HERE, right before WorldStateFrame at the tail, because the
# two files depend on each other through the stock UIParent_ManageFramePositions:
# ShapeshiftBar_OnLoad shows the bar, its OnShow runs the pass, and the pass indexes
# ReputationWatchBar, QuestTimerFrame, QuestWatchFrame, the chat frames and the rest UNGUARDED
# (UIParent.lua l.1592-1775) — so this file follows every one of them (ours seats the quest and
# chat files later than the reference does); and WorldStateAlwaysUpFrame's OnLoad runs the same
# pass, whose shapeshift-appearance arm indexes ShapeshiftBarLeft/Middle/Right (l.1710-1725) —
# so that file follows THIS one, as in the reference (l.74 then l.89). Beside the bar files this
# raised at UIParent.lua:1618 on every login and the shelf was never seated: the shelf art sat
# at its XML anchor until something re-ran the pass (the director's sliver above Defensive
# Stance; decision 2001). `every_load_time_runner_of_the_managed_pass_follows_what_it_reads`
# enforces both directions.
Interface\FrameXML\BonusActionBarFrame.xml
# ══ THE WORLD-STATE FRAMES — the reference's own, off the player's chain (1751; decision 1972) ══
# WorldStateFrame.xml + WorldStateFrame.lua: the always-up readout 1586 built (the tower/flag/base
# counters at top-centre, `UPDATE_WORLD_STATES` from `crate::world_state_ui`), the capture bars,
@@ -186,6 +186,9 @@ type ScreenParams<'w, 's> = (
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
Res<'w, Time<bevy::time::Virtual>>,
ResMut<'w, crate::perf::MainThreadSplit>,
// The additive blend-state check (`perf::blend_check`): draws whose bound blend state
// contradicted their material this frame — `blend_mismatch=` on the line.
Option<Res<'w, crate::perf::BlendMismatchShared>>,
);
#[derive(SystemParam)]
@@ -241,6 +244,10 @@ fn drive_live_fps(
let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8);
let paced_ms = screen.9.delta_secs() * 1000.0;
let main_split = &mut screen.10;
let blend_mismatch = screen
.11
.as_ref()
.map_or(0, |m| m.0.load(std::sync::atomic::Ordering::Relaxed));
// Read every frame (a reader that only reads inside the window would report the whole
// backlog on its first sampled frame).
let mesh_added = mesh_events
@@ -661,7 +668,7 @@ fn drive_live_fps(
);
}
let residency_line = format!(
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}{tiles} orphan_parts={orphans}",
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}{tiles} orphan_parts={orphans} stacked_parts={stacked} blend_mismatch={blend_mismatch}",
seen.mats,
seen.mats_parked,
seen.meshes,
@@ -699,6 +706,7 @@ fn drive_live_fps(
)
},
orphans = seen.orphan_parts,
stacked = seen.stacked_parts,
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}",
+67 -17
View File
@@ -99,6 +99,21 @@ type EntityLightReadout = (
Has<benilla_world::interior::ContainmentAttach>,
);
/// One pickable part as the card's `parts alive` and glow-card lines read it: the object, its
/// draw verdict, its transform, whether it is a billboard card, the material + mesh tag the glow
/// line names, and the visibility class the STACKED count reads. A tuple alias like
/// [`EntityLightReadout`] above, for the same reason: as an inline `Query<>` the field trips
/// clippy's `type_complexity` at the workspace gate.
type PartReadout = (
&'static benilla_world::interact::WorldObject,
&'static bevy::camera::visibility::ViewVisibility,
&'static GlobalTransform,
Has<benilla_world::billboard::BillboardCard>,
Option<&'static MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
Option<&'static bevy::mesh::MeshTag>,
Option<&'static bevy::camera::visibility::VisibilityClass>,
);
/// Everything the identity card reads off the **net entity** under the cursor, as one named
/// [`SystemParam`] — the descriptor store and the coarse kind the line gates go by, the GameObject
/// collision readout (decision 0763), the light readout, and the two remaining inputs of the
@@ -111,16 +126,11 @@ pub(super) struct InspectStores<'w, 's> {
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
/// Every pickable part with its draw verdict, for the card's `parts alive` line: a prop
/// spawned twice reads twice its model's batch count here, and nowhere else.
objects: Query<
'w,
's,
(
&'static benilla_world::interact::WorldObject,
&'static bevy::camera::visibility::ViewVisibility,
&'static GlobalTransform,
Has<benilla_world::billboard::BillboardCard>,
),
>,
objects: Query<'w, 's, PartReadout>,
/// The realized materials + images, for the card line: which material a glow card is bound
/// to, whether that material still says ADDITIVE, and whether its texture is resident.
model_mats: Res<'w, Assets<benilla_assets::materials::WowModelMaterial>>,
images: Res<'w, Assets<bevy::image::Image>>,
collision: Query<'w, 's, GoCollisionReadout>,
lit: Query<'w, 's, EntityLightReadout>,
motion: Query<'w, 's, MotionReadout>,
@@ -248,24 +258,64 @@ pub(super) fn inspect_ui(
// drew this frame. A doodad has one part per render batch; a doubled placement shows twice
// that here — the census the FPS probe prints as `orphan_parts=`, at the cursor.
let parts_line = {
let (mut alive, mut drawn) = (0usize, 0usize);
let (mut alive, mut drawn, mut stacked) = (0usize, 0usize, 0usize);
let mut cards: Vec<String> = Vec::new();
for (w, vv, gt, card) in stores.objects.iter() {
for (w, vv, gt, card, mat, tag, class) in stores.objects.iter() {
if w.kind == obj.kind && w.id == obj.id {
alive += 1;
drawn += usize::from(vv.get());
// A part whose `VisibilityClass` lists its mesh class more than once is queued
// that many times — drawn stacked on itself (`model_render::park`'s dedup).
stacked += usize::from(class.is_some_and(|c| c.len() > 1));
if card {
// A glow card's live world scale — the placement scale times the bone's
// pulse; a doubled or squared pulse reads here.
cards.push(format!("{:.2}", gt.compute_transform().scale.x));
// One glow card, as the draw sees it: its live world scale (the placement
// scale times the bone's pulse), its tag alpha, and the bound material's
// marker word — ADDITIVE is `clutter_fade.z` bit 2, the bit `specialize`
// keys the (ONE, ONE) blend on and the shader keys the alpha fold on; a card
// whose material lost it draws its texture unweighted, a hard bright disc.
let scale = gt.compute_transform().scale.x;
let alpha = tag.map_or(-1.0, |t| benilla_world::mesh_tag::alpha_of(t.0));
let m = mat.and_then(|m| stores.model_mats.get(&m.0));
let state = match m {
Some(m) => {
let z = m.extension.clutter_fade.z as u32;
let tex = m.base.base_color_texture.as_ref().map_or(
"none".to_string(),
|h| {
let loaded = stores.images.get(h).is_some();
format!("{}{}", h.id(), if loaded { "" } else { " MISSING" })
},
);
format!(
"add {} fade {:.0} unlit {:.0} nodw {} tex {tex}",
u8::from(z & 4 != 0),
m.extension.model_flags.y,
m.extension.model_flags.w,
u8::from(z & 1 != 0),
)
}
None => match mat {
Some(_) => "material UNREALIZED".to_string(),
None => "no material".to_string(),
},
};
cards.push(format!(
"[scale {scale:.2} α {alpha:.2} vis {} {state}]",
u8::from(vv.get())
));
}
}
}
let stacked = if stacked > 0 {
format!(", STACKED {stacked}")
} else {
String::new()
};
if cards.is_empty() {
format!("parts alive {alive}, drawn {drawn}")
format!("parts alive {alive}, drawn {drawn}{stacked}")
} else {
format!(
"parts alive {alive}, drawn {drawn}, cards {} (scale {})",
"parts alive {alive}, drawn {drawn}{stacked}, cards {} {}",
cards.len(),
cards.join(" ")
)
+6
View File
@@ -122,6 +122,12 @@ impl Plugin for DevProbesPlugin {
"WOW_PROBE_CLAM",
"WOW_RIG",
"WOW_LIVE_FPS",
// A screenshot burst and a pick burst are wall-clock schedules too, and on an
// occluded window they capture the same stale drawable over and over: a whole
// day of lamppost A/Bs on the Air read identical to the decimal because every
// frame in every burst was one frame (the halo record).
"WOW_LIVE_SHOT",
"WOW_PICK",
]
.iter()
.any(|k| std::env::var(k).is_ok())
+227
View File
@@ -0,0 +1,227 @@
//! **The additive blend-state check** — does every transparent draw bind the blend state its
//! material asked for?
//!
//! An additive glow card's look is two halves that must agree: the material's marker word
//! (`clutter_fade.z` bit 2) tells `wow_model.wgsl` to fold the radial alpha into the colour,
//! and the SAME marker, through `WowModelExt::specialize`, gives the pipeline its pure
//! `(ONE, ONE)` add. A draw that pairs the folded colour with an alpha-blend pipeline draws a
//! dim skirt; one that pairs an unfolded colour with the pure add draws the texture unweighted
//! — a hard, saturated disc where a soft halo should be. Decision 0071 measured that class on
//! macOS/Metal with the extra UI camera on the window (some draws intermittently bound a
//! blend-enabled pipeline) and immunised *opaque* batches with an alpha pin; glow cards were
//! left alone. The director's strong-halo report reads a correct main-world material on a
//! card whose draw is wrong, which is exactly the half this check reads.
//!
//! Render world, `Cleanup`, every frame, whatever the env: for each `Transparent3d` item whose
//! entity binds one of our materials, compare the pipeline descriptor's colour blend against
//! the material's additive marker. Mismatches are counted into a main-world-readable atomic
//! (the perf pill prints them red the frame they happen, so the director sees the count beside
//! the halo), and the first few of each second are logged with the entity, the material and
//! the blend state actually bound — the number and the names the fix will be judged against.
//! Cost: one hash probe and one descriptor read per transparent item, a few hundred a frame.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use bevy::asset::AssetId;
use bevy::core_pipeline::core_3d::Transparent3d;
use bevy::pbr::RenderMaterialInstances;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_phase::ViewSortedRenderPhases;
use bevy::render::render_resource::{BlendFactor, PipelineCache};
use bevy::render::{Render, RenderApp, RenderSystems};
use benilla_assets::materials::WowModelMaterial;
/// The realized materials whose marker word says ADDITIVE, by asset id — what a draw of theirs
/// must bind `(ONE, ONE)` for. Maintained from the asset events in the main world (a realized
/// material is Added, an edited one Modified), extracted as an `Arc` clone each frame.
#[derive(Resource, Clone, Default, ExtractResource)]
pub(crate) struct AdditiveMaterials(pub(crate) Arc<HashSet<AssetId<WowModelMaterial>>>);
/// Draws this frame whose bound blend state contradicted their material's additive marker.
/// Written by the render world's [`check_blend_states`], read by the pill and the probe.
#[derive(Resource, Clone)]
pub(crate) struct BlendMismatchShared(pub(crate) Arc<AtomicU64>);
/// One mismatching draw, tracked across frames so a transient (one frame after a material
/// swap, the specialization tick catching up) reads differently from a PERSISTING one — the
/// director's strong halo holds until a relog, and that difference is the finding.
pub(crate) struct Mismatch {
pub(crate) material: AssetId<WowModelMaterial>,
/// Whether the bound pipeline was the pure add (so the material was NOT additive), or the
/// reverse.
pub(crate) bound_add: bool,
/// Consecutive frames this entity has mismatched.
pub(crate) frames: u32,
/// The `frames` count the main world last reported at, so each entry logs on its first
/// frame and then once every couple of seconds while it persists.
pub(crate) reported: u32,
}
/// The live mismatch table, by main-world entity — kept by the render world, read and
/// narrated by [`report_blend_mismatches`] with the names only the main world knows.
#[derive(Resource, Clone, Default)]
pub(crate) struct BlendMismatchLive(pub(crate) Arc<Mutex<HashMap<Entity, Mismatch>>>);
/// Main world: keep [`AdditiveMaterials`] current off the material asset events.
fn track_additive_materials(
mut events: MessageReader<AssetEvent<WowModelMaterial>>,
materials: Res<Assets<WowModelMaterial>>,
mut set: ResMut<AdditiveMaterials>,
) {
let mut changed = false;
let mut next: Option<HashSet<AssetId<WowModelMaterial>>> = None;
for ev in events.read() {
let (id, present) = match ev {
AssetEvent::Added { id } | AssetEvent::Modified { id } => (*id, true),
AssetEvent::Removed { id } | AssetEvent::Unused { id } => (*id, false),
AssetEvent::LoadedWithDependencies { id } => (*id, true),
};
let additive = present
&& materials
.get(id)
.is_some_and(|m| (m.extension.clutter_fade.z as u32) & 4 != 0);
let s = next.get_or_insert_with(|| (*set.0).clone());
let moved = if additive {
s.insert(id)
} else {
s.remove(&id)
};
changed |= moved;
}
if changed {
if let Some(s) = next {
set.0 = Arc::new(s);
}
}
}
/// Render world, `Cleanup`: the check itself (module docs).
fn check_blend_states(
additive: Res<AdditiveMaterials>,
shared: Res<BlendMismatchShared>,
live: Res<BlendMismatchLive>,
instances: Res<RenderMaterialInstances>,
transparent: Option<Res<ViewSortedRenderPhases<Transparent3d>>>,
pipeline_cache: Res<PipelineCache>,
) {
let mut mismatches = 0u64;
let mut seen: HashSet<Entity> = HashSet::new();
let mut table = live.0.lock().unwrap();
if let Some(phases) = transparent {
for phase in phases.0.values() {
for item in &phase.items {
let Some(inst) = instances.instances.get(&item.entity.1) else {
continue;
};
let Ok(id) = inst.asset_id.try_typed::<WowModelMaterial>() else {
continue; // not one of ours (the sky dome's StandardMaterial, …)
};
let expect_add = additive.0.contains(&id);
let blend = pipeline_cache
.get_render_pipeline_descriptor(item.pipeline)
.fragment
.as_ref()
.and_then(|f| f.targets.first())
.and_then(|t| t.as_ref())
.and_then(|t| t.blend);
let is_add = blend.is_some_and(|b| {
b.color.src_factor == BlendFactor::One && b.color.dst_factor == BlendFactor::One
});
if expect_add == is_add {
continue;
}
mismatches += 1;
let main = item.entity.1.id();
if seen.insert(main) {
let e = table.entry(main).or_insert(Mismatch {
material: id,
bound_add: is_add,
frames: 0,
reported: 0,
});
e.material = id;
e.bound_add = is_add;
e.frames += 1;
}
}
}
}
table.retain(|e, _| seen.contains(e));
shared.0.store(mismatches, Ordering::Relaxed);
}
/// Main world, `PostUpdate`: narrate the live table with the names the render world lacks —
/// the object the entity is a part of, and the texture the material binds — on an entry's
/// first frame and then every two seconds while it persists. A one-frame entry is the
/// specialization tick catching up with a material swap; a persisting one is the defect.
fn report_blend_mismatches(
live: Res<BlendMismatchLive>,
objects: Query<&benilla_world::interact::WorldObject>,
materials: Res<Assets<WowModelMaterial>>,
server: Res<AssetServer>,
) {
let mut table = live.0.lock().unwrap();
for (entity, m) in table.iter_mut() {
if m.reported == m.frames || (m.frames != 1 && m.frames % 120 != 0) {
continue;
}
m.reported = m.frames;
let who = objects.get(*entity).map_or_else(
|_| "<no WorldObject>".to_string(),
|o| format!("{:?} #{} {}", o.kind, o.id, o.label),
);
let tex = materials
.get(m.material)
.and_then(|mat| mat.base.base_color_texture.as_ref())
.and_then(|h| server.get_path(h.id()))
.map_or("?".to_string(), |p| p.to_string());
let mat = match m.material {
AssetId::Index { index, .. } => format!("#{}", index.to_bits()),
AssetId::Uuid { uuid } => uuid.to_string(),
};
warn!(
"blend mismatch: {who} entity {entity} material {mat} tex {tex} — material \
additive={} but bound pipeline {} — {} frame(s){}",
!m.bound_add,
if m.bound_add {
"ADD (One,One)"
} else {
"alpha-blend"
},
m.frames,
if m.frames > 1 { " PERSISTING" } else { "" },
);
}
}
pub(crate) fn plugin(app: &mut App) {
let shared = Arc::new(AtomicU64::new(0));
let live = BlendMismatchLive::default();
app.init_resource::<AdditiveMaterials>()
.insert_resource(BlendMismatchShared(shared.clone()))
.insert_resource(live.clone())
.add_plugins(ExtractResourcePlugin::<AdditiveMaterials>::default())
// After the asset event flush, like bevy's own `check_entities_needing_specialization`:
// a material realized this frame is extracted, prepared and drawn this frame, and a
// tracker that reads its `Added` a frame late reports every first draw of an additive
// material as a mismatch — the rig's whole first harvest (glue sword glows, weapon
// sheens, the lamppost card), all one frame, all false.
.add_systems(
PostUpdate,
(
track_additive_materials.after(bevy::asset::AssetEventSystems),
report_blend_mismatches,
),
);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(BlendMismatchShared(shared))
.insert_resource(live)
.add_systems(Render, check_blend_states.in_set(RenderSystems::Cleanup));
}
+16 -3
View File
@@ -217,6 +217,7 @@ pub(super) fn pill_quads(
atlas: Option<Res<UiFontAtlas>>,
windows: Query<&Window, With<PrimaryWindow>>,
mut quads: ResMut<UiQuads>,
mismatch: Option<Res<super::BlendMismatchShared>>,
mut cache: Local<Option<PillCache>>,
) {
if !hud.visible {
@@ -227,8 +228,14 @@ pub(super) fn pill_quads(
};
let win_w = win.width();
let top = hud.pill_top();
let stale =
!matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w && c.top == top);
// Draws this frame that bound a blend state contradicting their material (the additive
// check, `perf::blend_check`): shown red the frame it happens, so a wrong halo on screen
// and a non-zero count here are seen together.
let mismatch = mismatch.map_or(0, |m| m.0.load(std::sync::atomic::Ordering::Relaxed));
let stale = !matches!(
&*cache,
Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w && c.top == top && c.mismatch == mismatch
);
if stale {
let cpu = hud.snap.cpu.mean();
let main = hud.snap.main.mean();
@@ -238,13 +245,16 @@ pub(super) fn pill_quads(
// sum dim at the end (every thread, the number a CPU % agrees with — decision 1954: a
// raid read 17 on it at a solid 60 with the main thread at 7, and the sum was taken for
// a frame time by everyone who looked at it).
let text = match (main, cpu) {
let mut text = match (main, cpu) {
(Some(main), Some(cpu)) => {
format!("{Q_DIM_MARKUP}{fps:.0} fps|r {main:.1} ms {Q_DIM_MARKUP}{cpu:.1} cpu|r")
}
(None, Some(cpu)) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} cpu"),
_ => format!("{Q_DIM_MARKUP}-- ms"),
};
if mismatch > 0 {
text.push_str(&format!(" |cffff5050blend x{mismatch}|r"));
}
let center = Vec2::new(win_w * 0.5, 0.0); // measured first, then shifted under PILL_TOP
let mut e = atlas.lock();
let glyphs = layout_text_quads(
@@ -294,6 +304,7 @@ pub(super) fn pill_quads(
snap_at: hud.snap_at,
win_w,
top,
mismatch,
quads: out,
});
}
@@ -307,6 +318,8 @@ pub(super) struct PillCache {
snap_at: f32,
win_w: f32,
top: f32,
/// The blend-mismatch count the text was laid out with (`perf::blend_check`).
mismatch: u64,
quads: Vec<UiQuad>,
}
+3
View File
@@ -39,6 +39,7 @@
//! GPU-bound frame is identified rather than timed: it is the one that runs long while the CPU
//! meters stay flat (read them side by side in the journal or a probe line).
mod blend_check;
mod census;
mod clock;
mod gpu;
@@ -54,6 +55,7 @@ mod trace;
use bevy::prelude::*;
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
pub(crate) use blend_check::BlendMismatchShared;
pub(crate) use clock::{process_cpu_secs, process_faults, system_cpu_ticks, thread_cpu_table};
pub(crate) use gpu::{GpuMsShared, WgpuCensusShared};
pub(crate) use hud::PerfHud;
@@ -167,5 +169,6 @@ impl Plugin for PerfPlugin {
// 1389 resolve-on-a-later-submission trap). Registers nothing when off, so campaign
// anchors never carry its ~0.03 ms sentinel cost uninvited.
gpu::plugin(app);
blend_check::plugin(app);
}
}
@@ -221,6 +221,19 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
script.pop_sound_suppression();
// **A handler that raised DURING the walk is a load failure.** The loader's own report holds
// the raises it dispatched itself (a `<Script file=>` chunk, an OnLoad); a raise one call
// deeper — an OnLoad that `Show()`s a frame whose OnShow indexes a global its file has not
// loaded yet — lands in the VM's error list instead, and until 2001 nothing read that list
// here: the stance bar's load-order defect shipped with the manifest reporting clean and a
// WARN line the smoke does not fail on. The errors stay in the VM (the player's dialog and
// the retained log still get them); this is the walk's own verdict growing the row.
failures.extend(
script
.errors()
.into_iter()
.map(|e| format!("a handler raised during the load walk: {e}")),
);
failures
}
@@ -397,6 +410,81 @@ mod tests {
/// `StaticPopup1`/`StaticPopup2`'s parents there would have written two declarations that did
/// nothing at all. The reference's own order is the fix (FrameXML.toc: BasicControls.xml l.6,
/// UIParent.xml l.8), and this keeps it.
/// **Every load-time runner of `UIParent_ManageFramePositions` follows every frame the pass
/// reads.** The stock pass (`UIParent.lua:1592-1775`) indexes a dozen HUD frames unguarded —
/// `ReputationWatchBar`, `QuestTimerFrame`, `QuestWatchFrame`, `MinimapCluster`,
/// `DurabilityFrame`, the bars — and several stock files run it from an OnLoad or the OnShow
/// of a frame their OnLoad shows. In the reference's toc the readers all precede the runners;
/// ours had the stance bar (a runner, through `ShapeshiftBar_OnLoad` → `Show` → `OnShow`)
/// three hundred lines above `ReputationFrame.xml` (a read), so every login raised at
/// `UIParent.lua:1618` and the shelf was never seated — the director's sliver above Defensive
/// Stance (decision 2001). The pairs below are the reference's own dependency, read off the
/// pass's body; a new runner or a new read joins the table, not a comment.
#[test]
fn every_load_time_runner_of_the_managed_pass_follows_what_it_reads() {
let files = manifest_files();
let at = |leaf: &str| {
files
.iter()
.position(|f| f.ends_with(&format!("\\{leaf}")))
.unwrap_or_else(|| panic!("the manifest lists {leaf}"))
};
// What the pass reads by name (`UIParent.lua:1598-1775`), and the file that declares it.
const READS: &[(&str, &str)] = &[
(
"MultiBarLeft / MultiBarRight / MultiBarBottomLeft",
"MultiActionBars.xml",
),
(
"PetActionBarFrame + SlidingActionBarTexture0/1",
"PetActionBarFrame.xml",
),
("ReputationWatchBar", "ReputationFrame.xml"),
(
"MainMenuExpBar / MainMenuBarMaxLevelBar / MainMenuBar",
"MainMenuBar.xml",
),
("CastingBarFrame", "CastingBarFrame.xml"),
("QuestTimerFrame", "QuestTimerFrame.xml"),
("QuestWatchFrame", "QuestLogFrame.xml"),
("DurabilityFrame + its three glyphs", "DurabilityFrame.xml"),
("MinimapCluster", "Minimap.xml"),
(
"ChatFrame1 / ChatFrame2 (+ FCF_DockUpdate)",
"FloatingChatFrame.xml",
),
// The shapeshift-appearance arm (l.1705-1732): a runner's file can be a READ too —
// the stance bar declares these and runs the pass, so it precedes the other runner.
(
"ShapeshiftBarLeft / Middle / Right",
"BonusActionBarFrame.xml",
),
];
// Who runs it at LOAD: an OnLoad, or the OnShow of a frame its OnLoad shows.
const RUNNERS: &[(&str, &str)] = &[
(
"ShapeshiftBar_OnLoad → Show → OnShow",
"BonusActionBarFrame.xml",
),
("WorldStateAlwaysUpFrame OnLoad", "WorldStateFrame.xml"),
];
for (what, runner) in RUNNERS {
for (name, read) in READS {
if read == runner {
continue; // its own declarations precede its own OnLoad
}
assert!(
at(read) < at(runner),
"{runner} ({what}) loads at {} but reads {name}, declared by {read} at {} — \
the pass raises at load and never seats the frame it was run for. Move the \
runner below the read, as the reference's toc has it.",
at(runner),
at(read)
);
}
}
}
#[test]
fn nothing_declares_a_uiparent_child_before_uiparent_itself_loads() {
let files = manifest_files();
+3
View File
@@ -853,6 +853,9 @@ mod pet_bar_tests;
#[cfg(test)]
mod pet_frame_tests;
#[cfg(test)]
mod pet_stable_tests;
#[cfg(test)]
mod tot_frame_tests;
@@ -0,0 +1,173 @@
//! The reference's own stable window (`Interface\FrameXML\PetStable.xml`, off the player's chain
//! since 1751) driven through the one event path that reaches it **while it is closed**:
//! `PetStable_OnLoad` registers `UNIT_PET`, and `PetStable_OnEvent` repaints on it
//! unconditionally (stock `PetStable.lua:20-21`) — so every Call Pet a hunter casts runs the whole
//! of `PetStable_Update` against a pet the client has only just learned about.
//!
//! That paint concatenates `UnitName("pet")` (l.129, and again at l.163-164) the moment
//! `UnitExists("pet")` is true, and the pet's name is the one name the client never has at that
//! moment: it does not ride the descriptor, it is answered by `CMSG_PET_NAME_QUERY` a round-trip
//! later. The reference reads `UNKNOWNOBJECT` there; a verb answering nil raises
//! `attempt to concatenate a nil value` in a window nobody opened (decision 2002).
use benilla_ui::script::{PetStats, ScriptValue, UiScript, UnitState};
use super::test_ui::load_ui_strict;
/// The window's production load prefix, in `benilla.toc` order: `GlobalStrings.lua` for
/// `UNIT_LEVEL_TEMPLATE`/`EMPTY_STABLE_SLOT` (and `UNKNOWNOBJECT`, the string under test),
/// `BasicControls.xml` for `TEXT()`, `ItemButtonTemplate.xml` for `SetItemButtonTexture`,
/// `MoneyFrame` for the cost row, `UIParent.xml` for the slot manager, `BuildListString` and
/// `Model_OnLoad` (the pane is a `<PlayerModel>`), `UIPanelTemplates` for the purchase and close
/// buttons, `GameTooltip.xml` for the slot hovers.
///
/// **Callers must open with `benilla_formats::wow_data_or_skip!()` themselves** — every entry
/// here is a chain entry, and the macro `return`s from the function it is written in.
const STABLE_UI: &[&str] = &[
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\Fonts.xml",
r"Interface\FrameXML\BasicControls.xml",
r"Interface\FrameXML\ItemButtonTemplate.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\GameTooltip.xml",
r"Interface\FrameXML\PetStable.xml",
];
fn load_stable() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in STABLE_UI {
load_ui_strict(&s, f);
}
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Benilla".into()),
health: 100,
max_health: 100,
level: 60,
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
s
}
/// The pet's snapshot exactly as `ui_pet::unit` pushes it on the tick the summon streams: the
/// descriptor's fields are in, the guid is in, and the name is whatever the name cache has —
/// `None` until `SMSG_PET_NAME_QUERY_RESPONSE` lands.
fn pet(name: Option<&str>) -> UnitState {
UnitState {
exists: true,
has_object: true,
name: name.map(String::from),
health: 900,
max_health: 1000,
level: 58,
guid: 0xF140_0000_0000_0001,
..UnitState::default()
}
}
/// The stat block the pet feed resolves off the creature template the same tick — the icon and
/// the family word come off one `CreatureFamily.dbc` row, which is why `GetPetIcon()` answers
/// (the paint's "is there a current pet?" test, l.51/161) while the NAME is still in flight.
fn hunter_pet_stats() -> PetStats {
PetStats {
icon: Some(r"Interface\Icons\Ability_Hunter_Pet_Boar".into()),
hunter_pet: true,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
family: Some("Boar".into()),
..PetStats::default()
}
}
fn level_text(s: &UiScript) -> String {
s.eval::<String>("return PetStableLevelText:GetText()")
.unwrap()
}
/// The director's report: a hunter with a pet out casts Call Pet, and the red script-error dialog
/// names `PetStable.lua:129` — with the stable window closed. `UNIT_PET` fires on the summon,
/// the paint runs, `UnitExists("pet")` is true, and the name is not known yet.
///
/// Failure looks like: `s.errors()` holds `PetStable.lua:129: attempt to concatenate a nil
/// value`, and the level line is whatever the XML shipped as placeholder text. The pass is the
/// reference's own line for that instant — `UNKNOWNOBJECT`, the level template, the family —
/// and no error.
#[test]
fn call_pet_repaints_the_closed_stable_with_unknownobject_while_the_name_is_in_flight() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_stable();
// No stable master has been visited: nothing pushed through `set_stable`, so every
// `GetStablePetInfo` answers its miss shape and `GetSelectedStablePet()` is -1.
s.set_unit("pet", Some(pet(None)));
s.set_pet_stats(true, hunter_pet_stats());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.errors().is_empty(),
"the closed stable window raised on Call Pet: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return PetStableFrame:IsVisible()").unwrap(),
"UNIT_PET must repaint the window without opening it"
);
assert_eq!(level_text(&s), "Unknown Level 58 Boar");
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Unknown",
"the slot's hover title is the same read (l.163)"
);
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltipSubtext")
.unwrap(),
"Level 58 Boar"
);
// The name lands; the next repaint reads it. (`UNIT_NAME_UPDATE` is not one of the window's
// events — a stable-side repaint is what the reference re-reads the name on.)
s.set_unit("pet", Some(pet(Some("Snarl"))));
s.fire_event("PET_STABLE_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(level_text(&s), "Snarl Level 58 Boar");
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Snarl"
);
}
/// The control the fix must not move: the dismiss edge. `UNIT_PET` with no pet token seated and
/// no stable row for slot 0 takes the "nothing" arm (l.151-156) — empty level line, no error.
#[test]
fn dismissing_the_pet_empties_the_closed_stable_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_stable();
s.set_unit("pet", Some(pet(None)));
s.set_pet_stats(true, hunter_pet_stats());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.set_unit("pet", None);
s.set_pet_stats(false, PetStats::default());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(level_text(&s), "");
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Empty Stable Slot"
);
}
+41 -2
View File
@@ -19,6 +19,19 @@ use super::{
const CLASS_ROGUE: u32 = 4;
const CLASS_DRUID: u32 = 11;
/// `FrameScript_GetText("UNKNOWNOBJECT")` as the name resolvers call it (`0x517220`, `0x609324`):
/// the VM's own GlobalString — read out of `_G` exactly as `0x703bf0` reads it, so a translated
/// `GlobalStrings.lua` translates this too (enUS: `"Unknown"`, `GlobalStrings.lua:4444`) — and,
/// when the global is missing or empty, the binary's own literal `"Unknown Being"` (`0x860fa4`,
/// the bytes at that address in `WoW.exe`). Always a string: this is the "name not yet known"
/// state, and it is never nil. Decision 2002.
fn unknownobject(lua: &Lua) -> mlua::Result<Value> {
match lua.globals().get::<Value>("UNKNOWNOBJECT") {
Ok(Value::String(s)) if !s.as_bytes().is_empty() => Ok(Value::String(s)),
_ => Ok(Value::String(lua.create_string("Unknown Being")?)),
}
}
/// Register the `Unit*` globals reading the per-token snapshot store (the same style/place the
/// object model and stdlib register their globals — bare globals on `_G`, matching the live API
/// surface).
@@ -171,10 +184,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `GetUnitName(unit, showServerName)` wrapper, which calls this binding with ONE
// argument. The engine's real second argument is a strict `LUA_TBOOLEAN` and is not
// modelled — no consumer passes it.
let name = with_unit(lua, &token, None, |u| u.name.clone())?;
//
// Value 1 — and the ONLY two nils a recognised token can produce (`0x517020`, wow-re
// `ui/scratch/binding-shape-arity-law.md` §2.1): the `"player"` fast path reads the
// local name buffer and pushes nil when it is empty (`0x517083` → `0x5abdc0`), and a
// token that resolves to GUID 0 pushes nil (`0x5170c0`). EVERY other path ends in a
// string — the cached name, or `FrameScript_GetText("UNKNOWNOBJECT")`: `0x517220` for
// a GUID with no object and no cache row, `0x609324` inside `CGUnit_C::GetUnitName`
// for a unit whose name cache has not answered or is stale (a pet's is
// `petnamecache.wdb`, keyed by `UNIT_FIELD_PETNUMBER`, `pet-action-bar-api.md`
// §11c.6). A freshly called pet is that case by construction: `UNIT_PET` fires off the
// descriptor and the name lands a `CMSG_PET_NAME_QUERY` round-trip later, and stock
// `PetStable.lua:129` concatenates the answer in between.
//
// "Resolved to a GUID" is a SEATED SNAPSHOT — not `exists`. The reference's
// `UnitExists` is a conjunction with `IsSelectable` (the `UnitState::exists` doc's
// named gap), so a not-selectable unit reads its name there with `UnitExists` nil; the
// name resolver's own nil is GUID 0 and nothing else, and a feed that seats a token
// has resolved it. Decision 2002.
let name = with_unit(lua, &token, None, |u| Some(u.name.clone()))?;
let name = match name {
Some(n) => Value::String(lua.create_string(&n)?),
None => Value::Nil,
Some(Some(n)) => Value::String(lua.create_string(&n)?),
Some(None)
if token
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("player")) =>
{
Value::Nil
}
Some(None) => unknownobject(lua)?,
};
Ok((name, Value::Nil))
})?,
+75 -1
View File
@@ -390,7 +390,81 @@ fn a_dead_unit_reports_dead_and_zero_health() {
assert!(s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
assert!(s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
// Name unknown (no name-query yet) → nil, the absent-name shape.
// Name unknown (no name-query yet) → `UNKNOWNOBJECT`, never nil: the unit RESOLVED, and the
// reference pushes nil for a zero GUID only (decision 2002; the shape is pinned below). A bare
// VM carries no GlobalStrings, so this is the binary's own literal.
assert_eq!(
s.eval::<String>(r#"return UnitName("target")"#).unwrap(),
"Unknown Being"
);
}
/// `UnitName`'s value 1 is nil in exactly two cases — a zero GUID, and the `"player"` fast path
/// over an empty local-name buffer — and a STRING everywhere else: the cached name, or
/// `FrameScript_GetText("UNKNOWNOBJECT")` for a unit that resolved but whose name the cache has not
/// answered (`0x517020` §2.1; `0x609324`). The stock stable window concatenates the answer on
/// `UNIT_PET` (`PetStable.lua:129`), the instant a called pet's name is still in flight — the
/// director's `attempt to concatenate a nil value` dialog (decision 2002).
#[test]
fn unitname_reads_unknownobject_for_a_resolved_unit_whose_name_is_in_flight() {
let mut s = UiScript::new().unwrap();
let pending = UnitState {
exists: true,
has_object: true,
name: None,
level: 58,
guid: 0xF140_0000_0000_0001,
..Default::default()
};
s.set_unit("pet", Some(pending.clone()));
// No GlobalStrings loaded: the literal the binary falls back to (`0x860fa4`).
assert_eq!(
s.eval::<String>(r#"return UnitName("pet")"#).unwrap(),
"Unknown Being"
);
// With the stock global seated, its (localizable) value — read out of the VM, not baked in.
s.run(r#"UNKNOWNOBJECT = "Unknown""#).unwrap();
assert_eq!(
s.eval::<String>(r#"return UnitName("pet")"#).unwrap(),
"Unknown"
);
// An EMPTY global is the same miss as an absent one (`0x609324`'s empty check).
s.run(r#"UNKNOWNOBJECT = """#).unwrap();
assert_eq!(
s.eval::<String>(r#"return UnitName("pet")"#).unwrap(),
"Unknown Being"
);
// Still two returns, the second nil (1840).
assert_eq!(
s.eval::<i64>(r#"local t = {UnitName("pet")}; return table.getn(t)"#)
.unwrap(),
1,
"a trailing nil is not counted by getn"
);
assert!(s
.eval::<bool>(r#"local _, realm = UnitName("pet"); return realm == nil"#)
.unwrap());
// The name lands: the string, and nothing else changes.
let mut named = pending.clone();
named.name = Some("Snarl".into());
s.set_unit("pet", Some(named));
assert_eq!(
s.eval::<String>(r#"return UnitName("pet")"#).unwrap(),
"Snarl"
);
// The `"player"` fast path never reaches the resolver: an empty local-name buffer is nil.
s.set_unit("player", Some(pending));
assert!(s
.eval::<bool>(r#"return UnitName("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitName("PLAYER") == nil"#)
.unwrap());
// A token nothing resolves is GUID 0 → nil (the shape `absent_token_…` pins too).
assert!(s
.eval::<bool>(r#"return UnitName("target") == nil"#)
.unwrap());
+1 -1
View File
@@ -1083,7 +1083,7 @@ fn seat_probe_slot(commands: &mut Commands, anchor: Entity, new: u16) {
commands.queue(
move |world: &mut World| match world.get_entity_mut(anchor) {
Ok(mut e) => {
e.remove::<PropProbeSlot>();
// One insert: the slot's `on_replace` hook frees the outgoing slot (2005).
e.insert(PropProbeSlot(new));
}
Err(_) => world.resource_mut::<PropProbes>().release(new),
@@ -267,16 +267,20 @@ pub(super) fn upload_prop_probes(
}
/// Attached to ONE entity of each lit interior prop instance (they despawn together with the
/// placement); the on-remove hook returns the slot to the table whoever despawns it.
/// placement); the hook returns the slot to the table whoever despawns it — and `on_replace`,
/// not `on_remove` (the `RigSkin` shape, decision 2005): `on_remove` never fires on an
/// insert-overwrite, so a re-seat that wrote a fresh slot over the old one leaked the old slot
/// for the life of the session, and only a remove-then-insert discipline at the one re-seat
/// site kept that from happening. `on_replace` fires once per transition on both edges.
#[derive(Component)]
#[component(on_remove = free_prop_probe_slot)]
#[component(on_replace = free_prop_probe_slot)]
pub struct PropProbeSlot(pub u16);
fn free_prop_probe_slot(mut world: DeferredWorld, ctx: HookContext) {
let slot = world
.get::<PropProbeSlot>(ctx.entity)
.map(|s| s.0)
.expect("on_remove runs with the component still present");
.expect("on_replace runs with the outgoing component still present");
world.resource_mut::<PropProbes>().release(slot);
}
@@ -304,6 +308,28 @@ mod tests {
assert_eq!(t.high, 2);
}
/// The hook is `on_replace`, so an insert over a live slot frees the old one — the leak
/// `on_remove` would have let through (it never fires on an overwrite).
#[test]
fn overwriting_a_probe_slot_frees_the_old_one() {
let mut w = World::new();
let mut t = PropProbes::default();
let a = t.alloc([Vec4::splat(0.5); 7]).unwrap();
let b = t.alloc([Vec4::splat(0.25); 7]).unwrap();
w.insert_resource(t);
let e = w.spawn(PropProbeSlot(a)).id();
w.entity_mut(e).insert(PropProbeSlot(b));
let t = w.resource::<PropProbes>();
assert_eq!(
t.rows[a as usize], [[0.0; 4]; 7],
"the overwritten slot is freed"
);
assert_ne!(t.rows[b as usize], [[0.0; 4]; 7], "the new slot is live");
w.entity_mut(e).despawn();
let t = w.resource::<PropProbes>();
assert_eq!(t.rows[b as usize], [[0.0; 4]; 7], "despawn still frees");
}
#[test]
fn owned_slots_never_dedup_and_update_in_place() {
let mut t = PropProbes::default();
+61 -1
View File
@@ -27,6 +27,10 @@
//! render layer of their own: a portrait booth's mirror parts show and hide with the booth's
//! wake, and its bake window is four frames.
use std::any::TypeId;
use bevy::camera::visibility::VisibilityClass;
use bevy::ecs::world::EntityWorldMut;
use bevy::prelude::*;
use super::ModelPart;
@@ -68,7 +72,15 @@ pub(super) fn park_hidden_parts(
commands
.entity(entity)
.insert(Mesh3d(parked.0.clone()))
.remove::<ParkedMesh>();
.remove::<ParkedMesh>()
// Bevy's `Mesh3d` add hook pushes the mesh's `VisibilityClass` entry every
// time the component is (re)added, and nothing ever dedups that list;
// `check_visibility` queues an entity once PER entry, so each park →
// unpark cycle drew the part one more time. Opaque parts hide it; an
// additive glow card stacks on itself — the director's lamppost halo,
// doubling after every teleport that hid it and healed only by a relog.
// One entry per class, applied after the hook has run.
.queue(dedup_visibility_class);
}
if let Some(mut f) = frames {
if f.0 != 0 {
@@ -93,6 +105,21 @@ pub(super) fn park_hidden_parts(
}
}
/// Leave one entry per class in an entity's [`VisibilityClass`] (see the unpark site).
fn dedup_visibility_class(mut e: EntityWorldMut) {
if let Some(mut class) = e.get_mut::<VisibilityClass>() {
let mut seen: Vec<TypeId> = Vec::with_capacity(class.len());
class.retain(|t| {
if seen.contains(t) {
false
} else {
seen.push(*t);
true
}
});
}
}
/// `WOW_NO_MESH_PARK=1` — the A/B lever.
pub(super) fn enabled() -> bool {
std::env::var_os("WOW_NO_MESH_PARK").is_none()
@@ -144,6 +171,39 @@ mod tests {
assert_eq!(w.entity(e).get::<HiddenFrames>().unwrap().0, 0);
}
/// Bevy's `Mesh3d` add hook pushes a `VisibilityClass` entry on every (re)add and
/// `check_visibility` queues an entity once per entry: without the dedup an unparked part
/// is drawn twice, three times after the next cycle — the stacking lamppost halo. The
/// world here registers the hook and the requirement exactly as bevy's plugin does.
#[test]
fn an_unparked_part_keeps_one_visibility_class_entry() {
let mut w = World::new();
w.register_required_components::<Mesh3d, VisibilityClass>();
w.register_component_hooks::<Mesh3d>()
.on_add(bevy::camera::visibility::add_visibility_class::<Mesh3d>);
let e = w
.spawn((
ModelPart {
kind: super::super::ModelKind::Doodad,
blend: benilla_formats::ModelBlend::Blend,
},
Mesh3d(Handle::default()),
InheritedVisibility::HIDDEN,
))
.id();
assert_eq!(w.entity(e).get::<VisibilityClass>().unwrap().len(), 1);
run(&mut w, PARK_AFTER_FRAMES as usize);
assert!(!w.entity(e).contains::<Mesh3d>(), "parked");
w.entity_mut(e).insert(InheritedVisibility::VISIBLE);
run(&mut w, 1);
assert!(w.entity(e).contains::<Mesh3d>(), "unparked");
assert_eq!(
w.entity(e).get::<VisibilityClass>().unwrap().len(),
1,
"the re-added Mesh3d's hook pushed a second class entry — the part draws twice"
);
}
#[test]
fn a_glance_never_parks() {
let (mut w, e) = world();
+11 -1
View File
@@ -81,6 +81,10 @@ pub struct CensusReport {
/// Placed (doodad/WMO) parts alive that no registered placement owns — the duplicate
/// census. Zero in a healthy world; a doubled prop is exactly one of these per part.
pub orphan_parts: usize,
/// Parts whose `VisibilityClass` holds a duplicate entry — each is queued and drawn once
/// per entry (`model_render::park`'s dedup is the fix; this is the census that names a
/// regression).
pub stacked_parts: usize,
/// The orphans by `(placement id, model label, parts)`, most parts first.
pub orphans: Vec<(u32, String, usize)>,
/// Resident tiles `(furnished, in window)`, off the streamer.
@@ -232,10 +236,14 @@ impl WorldCensus<'_, '_> {
let owned = self.placements.as_ref().map(|p| p.owned());
let mut orphans: HashMap<(u32, String), usize> = HashMap::new();
for (entity, vis, part, gated, object, want, aabb, card, group, path_why) in
let mut stacked_parts = 0usize;
for (entity, vis, part, gated, object, want, aabb, card, group, path_why, class) in
self.parts.iter()
{
submeshes += 1;
// A part whose `VisibilityClass` lists its mesh class more than once is queued
// that many times a frame — drawn stacked on itself (`model_render::park`).
stacked_parts += usize::from(class.is_some_and(|c| c.len() > 1));
// The duplicate census: a doodad/WMO part is spawned by exactly one placement and
// recorded on it; one alive outside every placement's list outlived a respawn. The
// one population that lives outside the registry by design is the retained pass's
@@ -313,6 +321,7 @@ impl WorldCensus<'_, '_> {
CensusReport {
submeshes,
drawn,
stacked_parts,
orphan_parts,
orphans,
tiles: self.streamer.as_ref().map(|s| s.residency()),
@@ -410,6 +419,7 @@ type CensusData = (
Has<crate::billboard::BillboardCard>,
Option<&'static WmoGroupVis>,
Option<&'static crate::model_render::EntityPathWhy>,
Option<&'static bevy::camera::visibility::VisibilityClass>,
);
/// The census column order, pinned: entry `i` names [`kind_index`]'s slot `i`. Column positions