From 6b812913f4424a2ed646e7793fef58c1a1b58ea5 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Sep 2026 19:32:13 +0700 Subject: [PATCH] the lamppost halo stops stacking, and the stance bar seats on login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the lamppost halo stops stacking after a short-range teleport: bevy registers `add_visibility_class::` 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 --- crates/benilla-app/assets/ui/benilla.toc | 19 +- .../src/capture/probes/live_fps.rs | 10 +- crates/benilla-app/src/debug_panel/inspect.rs | 84 +++++-- crates/benilla-app/src/dev.rs | 6 + crates/benilla-app/src/perf/blend_check.rs | 227 ++++++++++++++++++ crates/benilla-app/src/perf/hud.rs | 19 +- crates/benilla-app/src/perf/mod.rs | 3 + crates/benilla-app/src/ui_script/manifest.rs | 88 +++++++ crates/benilla-app/src/ui_script/mod.rs | 3 + .../src/ui_script/pet_stable_tests.rs | 173 +++++++++++++ crates/benilla-ui/src/script/unit/bindings.rs | 43 +++- crates/benilla-ui/src/script/unit/tests.rs | 76 +++++- crates/benilla-world/src/interior.rs | 2 +- .../benilla-world/src/lighting/prop_probes.rs | 32 ++- crates/benilla-world/src/model_render/park.rs | 62 ++++- crates/benilla-world/src/world_census.rs | 12 +- 16 files changed, 825 insertions(+), 34 deletions(-) create mode 100644 crates/benilla-app/src/perf/blend_check.rs create mode 100644 crates/benilla-app/src/ui_script/pet_stable_tests.rs diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 8250c900..76095f09 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -450,10 +450,6 @@ Interface\FrameXML\PetActionBarFrame.xml # (UI-AutoCastButton.mdx) and this engine's 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, diff --git a/crates/benilla-app/src/capture/probes/live_fps.rs b/crates/benilla-app/src/capture/probes/live_fps.rs index 9f6aae9d..cb570ee5 100644 --- a/crates/benilla-app/src/capture/probes/live_fps.rs +++ b/crates/benilla-app/src/capture/probes/live_fps.rs @@ -186,6 +186,9 @@ type ScreenParams<'w, 's> = ( MessageReader<'w, 's, AssetEvent>, Res<'w, Time>, 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>, ); #[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}", diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index d84f7f18..4e9d7373 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -99,6 +99,21 @@ type EntityLightReadout = ( Has, ); +/// 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, + Option<&'static MeshMaterial3d>, + 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, - ), - >, + 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>, + images: Res<'w, Assets>, 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 = 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(" ") ) diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 1902f7e2..e0f498d1 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -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()) diff --git a/crates/benilla-app/src/perf/blend_check.rs b/crates/benilla-app/src/perf/blend_check.rs new file mode 100644 index 00000000..6034ddd9 --- /dev/null +++ b/crates/benilla-app/src/perf/blend_check.rs @@ -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>>); + +/// 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); + +/// 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, + /// 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>>); + +/// Main world: keep [`AdditiveMaterials`] current off the material asset events. +fn track_additive_materials( + mut events: MessageReader>, + materials: Res>, + mut set: ResMut, +) { + let mut changed = false; + let mut next: Option>> = 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, + shared: Res, + live: Res, + instances: Res, + transparent: Option>>, + pipeline_cache: Res, +) { + let mut mismatches = 0u64; + let mut seen: HashSet = 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::() 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, + objects: Query<&benilla_world::interact::WorldObject>, + materials: Res>, + server: Res, +) { + 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( + |_| "".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::() + .insert_resource(BlendMismatchShared(shared.clone())) + .insert_resource(live.clone()) + .add_plugins(ExtractResourcePlugin::::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)); +} diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index 0683e5fc..4199025c 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -217,6 +217,7 @@ pub(super) fn pill_quads( atlas: Option>, windows: Query<&Window, With>, mut quads: ResMut, + mismatch: Option>, mut cache: Local>, ) { 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, } diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs index accd1318..ac1610bf 100644 --- a/crates/benilla-app/src/perf/mod.rs +++ b/crates/benilla-app/src/perf/mod.rs @@ -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); } } diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs index 4b97b5c1..be109a88 100644 --- a/crates/benilla-app/src/ui_script/manifest.rs +++ b/crates/benilla-app/src/ui_script/manifest.rs @@ -221,6 +221,19 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec { 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 `