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