From 39df11ee647025c5e39e02243d5759b8fa5406f2 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 28 Jul 2026 21:55:34 +0700 Subject: [PATCH] the teleport tour leak dies: a map you leave actually leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A tester handed us a clean repro: note your framerate, tour a few dungeons, come back — the client never recovers until restart. The cause: every dedup cache held its assets for the life of the process, and two material tickers touch every cached material every frame — so every map you visited made every later frame more expensive, everywhere. Caches are map-scoped now: one hop out of a dungeon measurably kills its whole payload (2,069 materials, ~300 models), and the perf probe grew a residency meter so this class of leak is machine-checked from here on. - Printed honestly: a second, far smaller retention (~25 models of toured display payload) survives the teardown, holder still unknown — the hunt continues, and the reporter's retest is the decisive check on the big one. - The sky stopped re-uploading its materials every frame at rest — sun, both moons, the star dome, the sky dome: ~17 uploads a frame with nothing moving on screen, now zero until a displayed byte actually changes. - The liquid animator sleeps on maps with no water, and the physics solver runs one substep instead of six — the world has no dynamic bodies to solve for. Co-Authored-By: Claude Fable 5 --- crates/benilla/src/assets/mod.rs | 60 ++++++++ crates/benilla/src/capture/probes.rs | 145 +++++++++++++++++++- crates/benilla/src/clouds/layer.rs | 4 +- crates/benilla/src/clouds/mod.rs | 2 + crates/benilla/src/clutter.rs | 18 ++- crates/benilla/src/entities.rs | 43 ++++++ crates/benilla/src/liquid/surface.rs | 22 ++- crates/benilla/src/main.rs | 7 + crates/benilla/src/nameplates.rs | 15 ++ crates/benilla/src/portrait/light.rs | 17 +++ crates/benilla/src/portrait/mod.rs | 2 + crates/benilla/src/sky.rs | 52 +++++-- crates/benilla/src/sound/kit.rs | 18 +++ crates/benilla/src/sun/follow.rs | 130 +++++++++++++----- crates/benilla/src/sun/materials.rs | 8 +- crates/benilla/src/sun/mod.rs | 2 +- crates/benilla/src/terrain_stream.rs | 5 + crates/benilla/src/ui_pass.rs | 2 +- crates/benilla/src/ui_script/extract/mod.rs | 20 ++- crates/benilla/src/world_map.rs | 35 ++++- 20 files changed, 546 insertions(+), 61 deletions(-) diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs index 770f372b..787dd388 100644 --- a/crates/benilla/src/assets/mod.rs +++ b/crates/benilla/src/assets/mod.rs @@ -83,6 +83,46 @@ pub(crate) fn normalize_path(path: &str) -> String { path.replace('/', "\\").to_ascii_lowercase() } +/// Mutate an asset only when `differs` says its current state doesn't already match. +/// +/// `Assets::get_mut` alone marks the asset Modified — a uniform re-upload and (on the Metal +/// non-bindless path) a bind-group rebuild that frame — so a per-frame writer re-pushing an +/// unchanged value is pure cost (the teleport leak's CPU engine was exactly this shape; the sky +/// family idled at ~17 such writes per frame). The gate decides on the immutable view; pair it +/// with values quantized to display precision ([`quantize`]/[`quant255`]) or continuous inputs +/// defeat the compare. +pub(crate) fn write_gated( + assets: &mut Assets, + handle: &Handle, + differs: impl Fn(&M) -> bool, + apply: impl FnOnce(&mut M), +) { + if assets.get(handle).is_none_or(differs) { + if let Some(m) = assets.get_mut(handle) { + apply(m); + } + } +} + +/// Quantize to `n`-ths — the write-gate's precision floor for continuously-drifting inputs +/// (time-of-day bands, camera-aim envelopes). Choose `n` at or below what the output can show: +/// the reference packs its celestial/sky lanes to bytes (`floor(255·…)`, the 0xFF broadcasts) +/// and re-submits them as per-frame vertex data, so a 1/255 step IS the reference's own +/// precision for color/alpha; geometry scalars gate at a sub-pixel step instead. +pub(crate) fn quantize(x: f32, n: f32) -> f32 { + (x * n).round() / n +} + +/// [`quantize`] each channel to the display's 1/255 LSB — the byte precision the reference's own +/// color lanes carry. +pub(crate) fn quant255(c: [f32; 3]) -> [f32; 3] { + [ + quantize(c[0], 255.0), + quantize(c[1], 255.0), + quantize(c[2], 255.0), + ] +} + /// Lock a `Mutex`, recovering the guard even if a previous holder **poisoned** it by panicking. /// /// The shared [`WorldAssets::chain`] is read from many systems; one panic mid-read would otherwise @@ -376,6 +416,26 @@ pub(crate) struct AssetPlugin; impl Plugin for AssetPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, open_world_assets.in_set(AssetSet::Open)); + app.add_systems(Update, evict_world_art); + } +} + +/// Drop the world-art dedup on a cross-map transition (`world_map::MapChange` — see its doc for +/// why a clear is always safe): `textures` + `model_materials` pin every map's world art forever +/// otherwise (the #bugs teleport leak). The UI sprite caches (`sprites`/`tiled_sprites`/ +/// `portraits`/`masks`) stay — they are game-global UI scope, and their negative entries exist +/// precisely to stop per-frame re-walks of the chain. +fn evict_world_art( + mut changes: MessageReader, + assets: Option>, +) { + if changes.is_empty() { + return; + } + changes.clear(); + if let Some(mut a) = assets { + a.textures.clear(); + a.model_materials.clear(); } } diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index 38c56298..22495311 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -417,7 +417,30 @@ impl Plugin for LiveFpsPlugin { samples: Vec::new(), cpu_at_start: None, }) - .add_systems(Update, drive_live_fps); + .init_resource::() + .add_systems(Update, drive_live_fps) + .add_systems( + First, + ( + ( + churn_counter::("image"), + churn_counter::("mesh"), + churn_counter::("std"), + churn_counter::("terrain"), + churn_counter::("model"), + churn_counter::("wdl"), + churn_counter::("liquid"), + ), + ( + churn_counter::("sky"), + churn_counter::("particle"), + churn_counter::("celestial"), + churn_counter::("star"), + churn_counter::("cloud"), + churn_counter::("uiquad"), + ), + ), + ); } } @@ -443,6 +466,97 @@ struct LiveFps { cpu_at_start: Option, } +/// Asset residency — the leak meter (the #bugs teleport leak: caches hold strong handles, so maps +/// visited earlier keep their materials/meshes/images resident, and every uv/tint registry survivor +/// is re-uploaded per frame). A tour probe reading the same counts as a fresh control is what +/// "torn down" means, machine-checked. One struct because `drive_live_fps` is at Bevy's +/// system-param arity limit. +#[derive(bevy::ecs::system::SystemParam)] +struct ResidencyMeter<'w> { + mats: Res<'w, Assets>, + meshes: Res<'w, Assets>, + images: Res<'w, Assets>, + uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>, + tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>, + models: Res<'w, Assets>, + server: Res<'w, AssetServer>, + churn: ResMut<'w, ChurnCensus>, +} + +/// The asset-churn census (the probe's ratchet meter): `AssetEvent::Modified` counts per asset +/// type across the sample window, printed as one `MAT_CHURN` line beside `FPS_PROBE`. A modified +/// material re-creates its uniform buffers + bind group that frame (the Metal non-bindless path), +/// and a modified image/mesh re-uploads — the teleport leak's CPU engine was exactly a per-frame +/// ratchet of this shape, so the floor hunt names them instead of guessing suspects one at a +/// time. Counters register only under `WOW_LIVE_FPS` ([`LiveFpsPlugin`]) — a normal run carries +/// none of this. +#[derive(Resource, Default)] +struct ChurnCensus(std::collections::BTreeMap<&'static str, usize>); + +/// One census counter for asset type `A`, folding this frame's `Modified` events in under +/// `label` (a short stable name — the `type_name` of an `ExtendedMaterial` alias is unreadable). +fn churn_counter( + label: &'static str, +) -> impl FnMut(MessageReader>, ResMut) { + move |mut reader, mut census| { + let n = reader + .read() + .filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. })) + .count(); + if n > 0 { + *census.0.entry(label).or_default() += n; + } + } +} + +impl ResidencyMeter<'_> { + /// `WOW_ASSET_DUMP=1`: one `ASSET_DUMP` line per resident image/mesh at sample time, path + /// via the asset server (runtime-built assets have no path and print as `` counts). + /// Diffing a tour probe's dump against a fresh control's names exactly which files a + /// teardown left behind — the leak meter's magnifying glass. + fn dump(&self) { + let mut lines: Vec = Vec::new(); + let mut unpathed = [0usize; 3]; + for (kind, ids) in [ + ( + "image", + self.images.ids().map(|i| i.untyped()).collect::>(), + ), + ( + "mesh", + self.meshes.ids().map(|i| i.untyped()).collect::>(), + ), + ( + "model", + self.models.ids().map(|i| i.untyped()).collect::>(), + ), + ] { + let slot = match kind { + "image" => 0, + "mesh" => 1, + _ => 2, + }; + for id in ids { + match self.server.get_path(id) { + Some(p) => lines.push(format!("ASSET_DUMP {kind} {p}")), + None => unpathed[slot] += 1, + } + } + } + lines.sort(); + for l in &lines { + println!("{l}"); + } + println!( + "ASSET_DUMP images={} meshes={} models={} materials={}", + unpathed[0], + unpathed[1], + unpathed[2], + self.mats.len() + ); + } +} + /// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the /// harness probe's `Phase::ProbeWarmup`/`Probing` arms. #[allow(clippy::too_many_arguments)] @@ -467,6 +581,7 @@ fn drive_live_fps( // probe line proves the palette lane is actually populated (an all-zero table renders // origin-collapsed rigs, which no other probe number would catch). palettes: Option>, + mut residency: ResidencyMeter, mut keys: ResMut>, mut exit: MessageWriter, ) { @@ -500,6 +615,9 @@ fn drive_live_fps( LiveFpsPhase::Sampling => { if probe.samples.is_empty() { probe.cpu_at_start = crate::perf::process_cpu_secs(); + // The churn census restarts with the window — warmup noise (streaming, shader + // warms) would otherwise read as steady-state ratchets. + residency.churn.0.clear(); } let ms = time.delta_secs() * 1000.0; probe.samples.push(ms); @@ -558,8 +676,16 @@ fn drive_live_fps( ) }) .unwrap_or_default(); + let residency_line = format!( + " mats={} meshes={} images={} uv={} tint={}", + residency.mats.len(), + residency.meshes.len(), + residency.images.len(), + residency.uv_reg.0.len(), + residency.tint_reg.0.len(), + ); 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={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs} px={}x{}{cpu}{present}{at_pin}", + "FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{present}{at_pin}", v.len(), at(0.50), at(0.95), @@ -572,6 +698,21 @@ fn drive_live_fps( px.0, px.1, ); + // The window's Modified-event totals per asset type — a type at ~1×/frame here is a + // per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet. + if !residency.churn.0.is_empty() { + let churn = residency + .churn + .0 + .iter() + .map(|(k, n)| format!("{k}={n}")) + .collect::>() + .join(" "); + println!("MAT_CHURN frames={} {churn}", v.len()); + } + if std::env::var_os("WOW_ASSET_DUMP").is_some() { + residency.dump(); + } if probe.run { keys.release(KeyCode::KeyW); } diff --git a/crates/benilla/src/clouds/layer.rs b/crates/benilla/src/clouds/layer.rs index 5d62e94f..70a61982 100644 --- a/crates/benilla/src/clouds/layer.rs +++ b/crates/benilla/src/clouds/layer.rs @@ -36,14 +36,14 @@ use super::kernel::COLS; /// The cloud dome material: unlit premultiplied-gamma blend over the sky, coverage from the R8 /// tile texture, palette/glow uniforms packed like `SkyExt`. -pub(super) type CloudMaterial = ExtendedMaterial; +pub(crate) type CloudMaterial = ExtendedMaterial; /// The colored cloud texture (see `cloud.wgsl`). All color math already happened CPU-side in the /// kernel's `0x6cfb00` port (gradient + glow + weather dim, in gamma bytes exactly like the /// reference) — the material only carries the resulting RGBA image, whose texels are **raw gamma /// values** (a non-sRGB texture, never linearised). #[derive(Asset, AsBindGroup, Clone, TypePath)] -pub(super) struct CloudExt { +pub(crate) struct CloudExt { /// The live colored tile (RGBA8, 128², alpha = coverage), re-uploaded on regen — the /// reference's `0x58ac70` zero-copy bind of the `0x6cfb00` color buffer (Addendum A §3). #[texture(100)] diff --git a/crates/benilla/src/clouds/mod.rs b/crates/benilla/src/clouds/mod.rs index 4984613b..e5389eb2 100644 --- a/crates/benilla/src/clouds/mod.rs +++ b/crates/benilla/src/clouds/mod.rs @@ -24,6 +24,8 @@ use crate::lighting::WowLighting; mod kernel; mod layer; + +pub(crate) use layer::CloudMaterial; mod tables; pub use kernel::{occ1_moon, occ1_sun}; diff --git a/crates/benilla/src/clutter.rs b/crates/benilla/src/clutter.rs index 7da57a87..a1a026d0 100644 --- a/crates/benilla/src/clutter.rs +++ b/crates/benilla/src/clutter.rs @@ -33,7 +33,23 @@ pub(crate) struct ClutterPlugin; impl Plugin for ClutterPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, setup_clutter.after(AssetSet::Open)) - .add_systems(Update, stream_chunk_clutter); + .add_systems(Update, (stream_chunk_clutter, evict_clutter_geometry)); + } +} + +/// Drop the decoded clutter-geometry cache on a cross-map transition (`world_map::MapChange` — +/// see its doc): it holds CPU submesh copies of every detail M2 ever decoded, and clutter models +/// are map-flavored (a new map re-decodes its own handful on first build). +fn evict_clutter_geometry( + mut changes: MessageReader, + geometry: Option>, +) { + if changes.is_empty() { + return; + } + changes.clear(); + if let Some(mut g) = geometry { + g.0.clear(); } } diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index efd8dc05..481c5a98 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -296,6 +296,46 @@ pub(crate) struct VisualAttached; #[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)] pub(crate) struct EntityVisualsSet; +/// Drop every display/material dedup on a cross-map transition (`world_map::MapChange` — see its +/// doc for why a clear is always safe mid-session). These caches are get-or-insert at every use +/// site, so a cleared entry rebuilds on the next spawn that wants it; without this, every display +/// id, material key, and composited skin ever seen stayed resident for the life of the process. +fn evict_display_caches( + mut changes: MessageReader, + mut entity_mats: ResMut, + mut composites: ResMut, + mut fx: ResMut, + creatures: Option>, + gos: Option>, + items: Option>, +) { + if changes.is_empty() { + return; + } + changes.clear(); + info!( + "display caches evicted: {} materials, {} creature / {} go / {} item / {} fx models, {} skins", + entity_mats.0.len(), + creatures.as_ref().map_or(0, |c| c.models.len()), + gos.as_ref().map_or(0, |g| g.models.len()), + items.as_ref().map_or(0, |i| i.models.len()), + fx.models.len(), + composites.0.len(), + ); + entity_mats.0.clear(); + composites.0.clear(); + fx.models.clear(); + if let Some(mut c) = creatures { + c.models.clear(); + } + if let Some(mut g) = gos { + g.models.clear(); + } + if let Some(mut i) = items { + i.models.clear(); + } +} + /// The streamed-entity subsystem: builds the shared cube assets + display catalogs at startup, then /// each frame resolves/builds display models and attaches a visual to every net entity. pub(crate) struct EntitiesPlugin; @@ -311,6 +351,9 @@ impl Plugin for EntitiesPlugin { // The projectile flight-loop edges (`crate::sound::missile` consumes them). .add_message::() .add_systems(Startup, setup_entities.after(AssetSet::Open)) + // The map-scope teardown (`world_map::MapChange`): drop every display/material dedup + // so a map's assets actually die with it — the #bugs teleport leak. + .add_systems(Update, evict_display_caches) // Every streamed unit's collision height, the frame after `apply_net_updates` spawns it // (that stage's Commands are what create the entity, so this cannot be earlier). Its // consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's diff --git a/crates/benilla/src/liquid/surface.rs b/crates/benilla/src/liquid/surface.rs index 528c7ee5..137790e0 100644 --- a/crates/benilla/src/liquid/surface.rs +++ b/crates/benilla/src/liquid/surface.rs @@ -419,10 +419,19 @@ pub(super) fn animate_liquid( liquid: Option>, mut materials: ResMut>, mut last_ticks: Local>, + surfaces: Query<(), With>>, ) { let Some(liquid) = liquid else { return; }; + // No liquid mesh in the world → nothing samples these materials; skip the whole cycle (the + // startup-built kind set otherwise re-uploads ~10 materials at every 24 Hz edge on maps with + // no water at all). The cycler is wall-clock ([`ANIM_FPS`] × elapsed), so when the first + // surface streams in the index resumes at the current wall frame — exactly the reference's + // `GetTickCount`-driven phase. + if surfaces.is_empty() { + return; + } // Captures pin the cycler to frame 0: the wall-clock at screenshot time varies with load // times, so any framing with open water diffs differently run to run — the flake substrate's // baseline redesign caught (MAE 3.97 → 0.009 pinned; decision 0600). One clause, one frame. @@ -436,9 +445,16 @@ pub(super) fn animate_liquid( } *last_ticks = Some(ticks); for entry in liquid.materials.values() { - if let Some(m) = materials.get_mut(&entry.material) { - m.extension.anim.x = (ticks % entry.frame_count.max(1)) as f32; - } + // Gated per entry: a single-frame liquid (`frame_count` 1) never changes index, and the + // shared cache holds every kind ever built — re-marking them all Modified on each tick + // edge re-uploaded ~10 materials/frame on maps with no water at all. + let frame = (ticks % entry.frame_count.max(1)) as f32; + crate::assets::write_gated( + &mut materials, + &entry.material, + |m| m.extension.anim.x != frame, + |m| m.extension.anim.x = frame, + ); } } diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index 71914ed6..c47a9765 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -388,6 +388,13 @@ fn main() { // player drives `MoveAndSlide` against them. WoW gravity (19.29 yd/s², binary-derived — now a // feel knob, not a fidelity target) replaces avian's 9.81 default. .add_plugins(PhysicsPlugins::default()) + // One solver substep, not avian's 6: the world has NO dynamic bodies (static terrain, + // kinematic transports/attachments; the player is a shape-cast controller), so the substep + // loop's contact/joint solving iterates over nothing — and kinematic motion integrates + // exactly (constant velocity, no forces) at any substep count. Six substeps were pure + // fixed-tick schedule overhead (~10 substep-schedule runs per frame on the idle-floor + // ledger). Revisit if a dynamic body ever enters the world. + .insert_resource(avian3d::prelude::SubstepCount(1)) .insert_resource(Gravity(Vec3::NEG_Y * 19.291_105)) // The per-frame world-transition ordering (Input → Stream → Present) the loading screen relies // on to cover a teleport the same frame it happens. See `schedule.rs`. diff --git a/crates/benilla/src/nameplates.rs b/crates/benilla/src/nameplates.rs index 3740c692..67c1095f 100644 --- a/crates/benilla/src/nameplates.rs +++ b/crates/benilla/src/nameplates.rs @@ -626,6 +626,7 @@ impl Plugin for NameplatesPlugin { .after(crate::vplates::VPlateSet) .after(crate::chat_bubble::BubbleSet), ) + .add_systems(Update, evict_name_meshes) .add_systems( PostUpdate, place_nameplates @@ -635,6 +636,20 @@ impl Plugin for NameplatesPlugin { } } +/// Drop the per-line-stack mesh dedup on a cross-map transition (`world_map::MapChange` — see its +/// doc): it grows with every distinct name ever seen and the old map's population never comes +/// back. The palette `materials` stay — a small fixed set, rebuilt only if the atlas changes. +fn evict_name_meshes( + mut changes: MessageReader, + mut plates: ResMut, +) { + if changes.is_empty() { + return; + } + changes.clear(); + plates.meshes.clear(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla/src/portrait/light.rs b/crates/benilla/src/portrait/light.rs index 23cbf241..a776570b 100644 --- a/crates/benilla/src/portrait/light.rs +++ b/crates/benilla/src/portrait/light.rs @@ -58,6 +58,23 @@ pub(super) struct BoothLight { pub(super) pane: BoothRig, } +/// Reap the booth twins whose world source material died (`AssetEvent::Removed` — e.g. the +/// map-scope teardown, `world_map::MapChange`). A twin is its own asset pinned only by this +/// cache, so without the reap every world material ever baked through a booth would survive +/// the teardown forever (the #bugs teleport leak, multiplied per rig). A live bake keeps its +/// twin through its own `MeshMaterial3d` clone; only the dedup entry drops. +pub(super) fn reap_dead_variants( + mut events: MessageReader>, + mut booths: ResMut, +) { + for ev in events.read() { + if let AssetEvent::Removed { id } = ev { + booths.studio.variants.remove(id); + booths.pane.variants.remove(id); + } + } +} + /// The twin of a world-built material against `buffer`, cached in `variants` — same /// texture/blend/flags, only the light storage swapped. [`BoothRig::variant`] is this against one of /// the two fixed booth buffers; the create scene passes its own authored-rig buffer with `rig` set, diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index 99a1c96d..7048cdf4 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -284,6 +284,8 @@ impl Plugin for PortraitPlugin { .init_resource::() .init_resource::() .add_systems(Startup, setup_booths) + // The variant-cache reaper: booth twins die with their world source material. + .add_systems(Update, light::reap_dead_variants) // `feed_interact_npc` resolves the `"npc"` token's entity first; then the test bake owns // the booths when its env is set (the live syncs yield to it). The paper-doll sync runs // last (it shares the camera/booth/image resources, so the chain keeps the access ordered). diff --git a/crates/benilla/src/sky.rs b/crates/benilla/src/sky.rs index 5d57197e..a40277aa 100644 --- a/crates/benilla/src/sky.rs +++ b/crates/benilla/src/sky.rs @@ -260,22 +260,50 @@ fn update_sky_colors( let Ok(handle) = dome.single() else { return; }; - let Some(mat) = materials.get_mut(&handle.0) else { - return; - }; - mat.extension.sky0 = col(light.sky[0]); - mat.extension.sky1 = col(light.sky[1]); - mat.extension.sky2 = col(light.sky[2]); - mat.extension.sky3 = col(light.sky[3]); - mat.extension.sky4 = col(light.sky[4]); - let f = light.fog_color; + // Byte-quantized (`quant255` — Light.dbc stops are byte colors) and write-gated: the stops + // drift continuously with time-of-day, but an `Assets::get_mut` alone re-uploads the material + // every frame; gated, the dome only pays when a display-visible band actually moves. + let sky: Vec = light + .sky + .iter() + .map(|c| col(crate::assets::quant255(*c))) + .collect(); + let f = crate::assets::quant255(light.fog_color); // `fog.w` unused by the shader (the old raw-vs-linearised A/B is gone — GAMMA LANE, 0161). - mat.extension.fog = Vec4::new(f[0], f[1], f[2], 0.0); + let fog = Vec4::new(f[0], f[1], f[2], 0.0); // Dawn/dusk warp: strength S + the sun's compass azimuth (Bevy world `atan2(z, x)` of the // camera→sun direction). The warp's glow table is symmetric about this bearing, so the sun-facing // dome quarter warms and the opposite side desaturates. S=0 (all midday / highlightSky=0 zones) → - // the shader leaves the gradient untouched. + // the shader leaves the gradient untouched. Azimuth/strength gate at 1/4096 — sub-visible + // steps of the warp's smooth glow table. let s = light.celestial_dir; let sun_az = s.z.atan2(s.x); - mat.extension.warp = Vec4::new(light.sky_warp, sun_az, 0.0, 0.0); + let warp = Vec4::new( + crate::assets::quantize(light.sky_warp, 4096.0), + crate::assets::quantize(sun_az, 4096.0), + 0.0, + 0.0, + ); + crate::assets::write_gated( + &mut materials, + &handle.0, + |m| { + m.extension.sky0 != sky[0] + || m.extension.sky1 != sky[1] + || m.extension.sky2 != sky[2] + || m.extension.sky3 != sky[3] + || m.extension.sky4 != sky[4] + || m.extension.fog != fog + || m.extension.warp != warp + }, + |m| { + m.extension.sky0 = sky[0]; + m.extension.sky1 = sky[1]; + m.extension.sky2 = sky[2]; + m.extension.sky3 = sky[3]; + m.extension.sky4 = sky[4]; + m.extension.fog = fog; + m.extension.warp = warp; + }, + ); } diff --git a/crates/benilla/src/sound/kit.rs b/crates/benilla/src/sound/kit.rs index 2f87178f..5c29adf6 100644 --- a/crates/benilla/src/sound/kit.rs +++ b/crates/benilla/src/sound/kit.rs @@ -581,6 +581,23 @@ fn stop_all_channels(mut out: NonSendMut) { } } +/// Drop the decoded-SFX cache on a cross-map transition (`world_map::MapChange` — see its doc): +/// kit variations are decoded on demand, so a new map's soundscape rebuilds its own working set +/// while the old map's decodes stop occupying RAM forever (the #bugs teleport leak). Playing +/// channels own their frames (`StaticSoundData` clones share them), so nothing audible cuts. +fn evict_kit_cache( + mut changes: MessageReader, + kits: Option>, +) { + if changes.is_empty() { + return; + } + changes.clear(); + if let Some(mut k) = kits { + k.cache.clear(); + } +} + /// Registration hook for [`super::SoundPlugin`]. pub(super) fn plugin(app: &mut App) { app.add_systems(Startup, load_sound_kits.after(AssetSet::Open)) @@ -589,6 +606,7 @@ pub(super) fn plugin(app: &mut App) { ( pump_channels.in_set(crate::schedule::WorldStage::Present), apply_kit_debug, + evict_kit_cache, ), ) .add_systems( diff --git a/crates/benilla/src/sun/follow.rs b/crates/benilla/src/sun/follow.rs index 02894988..1379fd80 100644 --- a/crates/benilla/src/sun/follow.rs +++ b/crates/benilla/src/sun/follow.rs @@ -121,6 +121,19 @@ pub(super) fn disc_span(dir_y: f32, size: f32) -> Vec4 { Vec4::new((elev - half).sin(), (elev + half).sin(), 0.0, 0.0) } +/// [`crate::assets::quantize`] a [`disc_span`] to 1/4096 sin-elevation steps — the write gate's +/// floor for the one geometry lane the celestial writes carry. A step is ≪ one pixel of fade edge +/// on any disc, while un-quantized the span drifts every frame with time-of-day and would defeat +/// the gate. +fn quant_span(v: Vec4) -> Vec4 { + Vec4::new( + crate::assets::quantize(v.x, 4096.0), + crate::assets::quantize(v.y, 4096.0), + v.z, + v.w, + ) +} + /// True when no resident-terrain column blocks the camera→body ray within [`FLARE_RAY_RANGE`]. /// Pure over a height oracle (`Some(ground WoW z)` under a Bevy point, `None` = no terrain in that /// column — never an occluder), so the march is unit-testable without a streamed world. Quadratic @@ -286,7 +299,9 @@ pub(super) fn follow_sun( let rot = Quat::from_rotation_arc(Vec3::Z, -to_light); let hidden = debug.lighting.disable_sky_dome; // the sun is part of the sky — hide it with the dome let f = view_lerp(*cam_gt.forward(), to_light); - let tint = light.celestial_tint; + // Byte-quantized at the source ([`crate::assets::quant255`]) so the write gates below only + // fire on a display-visible change — the reference's own color lanes are bytes. + let tint = crate::assets::quant255(light.celestial_tint); // The weather celestial-alpha seed — dims the disc + glare under active weather. let seed = celestial_alpha_seed(light.storm_bcc); // The occlusion probe covers the disc's own quad (Addendum #8): its angular half-size. @@ -313,13 +328,25 @@ pub(super) fn follow_sun( SUN_SIZE * light.sun_disc_scale }; tf.scale = Vec3::splat(size * dist); - if let Some(m) = mats.get_mut(&mat.0) { - m.base.base_color = Color::srgb(tint[0], tint[1], tint[2]); - m.extension.span = disc_span(to_light.y, size); - // Above-band disc opacity: the 0xFF broadcast, overwritten by the weather - // seed under active weather ([`celestial_alpha_seed`]). - m.extension.fade.w = seed.unwrap_or(1.0); - } + let color = Color::srgb(tint[0], tint[1], tint[2]); + let span = quant_span(disc_span(to_light.y, size)); + // Above-band disc opacity: the 0xFF broadcast, overwritten by the weather + // seed under active weather ([`celestial_alpha_seed`]). + let fade_w = crate::assets::quantize(seed.unwrap_or(1.0), 255.0); + crate::assets::write_gated( + &mut mats, + &mat.0, + |m| { + m.base.base_color != color + || m.extension.span != span + || m.extension.fade.w != fade_w + }, + |m| { + m.base.base_color = color; + m.extension.span = span; + m.extension.fade.w = fade_w; + }, + ); } SunPart::Glare => { tf.translation = cam_pos + to_light * GLARE_DIST; @@ -334,10 +361,15 @@ pub(super) fn follow_sun( // SRC_ALPHA byte weighting (decision 0502). // × the weather seed — the glare's alpha byte is the seed the per-frame pack // modulates (Addendum #6: `oldByte` into `0x6cf490`), so storms dim the flare. - let env = (0.5 + 0.5 * f) * env30 * seed.unwrap_or(1.0); - if let Some(m) = mats.get_mut(&mat.0) { - m.base.base_color = Color::srgba(tint[0], tint[1], tint[2], env); - } + let env = + crate::assets::quantize((0.5 + 0.5 * f) * env30 * seed.unwrap_or(1.0), 255.0); + let color = Color::srgba(tint[0], tint[1], tint[2], env); + crate::assets::write_gated( + &mut mats, + &mat.0, + |m| m.base.base_color != color, + |m| m.base.base_color = color, + ); } } // Propagation already ran this frame — the direct global write is what renders. @@ -373,7 +405,9 @@ pub(super) fn follow_moons( let dist = far * 0.85; let cam_pos = cam_gt.translation(); let hidden = debug.lighting.disable_sky_dome; - let tint = light.celestial_tint; + // Byte-quantized at the source, like [`follow_sun`] — the write gates fire on display-visible + // change only. + let tint = crate::assets::quant255(light.celestial_tint); // The weather celestial-alpha seed — dims both moons' discs + the glare under active weather. let seed = celestial_alpha_seed(light.storm_bcc); // One slewed envelope per frame, along the white moon's ray (only its glare exists). The moon's @@ -416,11 +450,23 @@ pub(super) fn follow_moons( SUN_SIZE * 1.75 * light.moon_disc_scale }; tf.scale = Vec3::splat(size * dist); - if let Some(m) = mats.get_mut(&mat.0) { - m.base.base_color = Color::srgb(tint[0], tint[1], tint[2]); - m.extension.span = disc_span(to_moon.y, size); - m.extension.fade.w = seed.unwrap_or(1.0); - } + let color = Color::srgb(tint[0], tint[1], tint[2]); + let span = quant_span(disc_span(to_moon.y, size)); + let fade_w = crate::assets::quantize(seed.unwrap_or(1.0), 255.0); + crate::assets::write_gated( + &mut mats, + &mat.0, + |m| { + m.base.base_color != color + || m.extension.span != span + || m.extension.fade.w != fade_w + }, + |m| { + m.base.base_color = color; + m.extension.span = span; + m.extension.fade.w = fade_w; + }, + ); } MoonPart::Moon02 => { // Base ×1.0, the shared size curve on moon02's own phase clock. The binary never @@ -436,10 +482,17 @@ pub(super) fn follow_moons( SUN_SIZE * light.moon02_disc_scale }; tf.scale = Vec3::splat(size * dist); - if let Some(m) = mats.get_mut(&mat.0) { - m.extension.span = disc_span(to_moon.y, size); - m.extension.fade.w = seed.unwrap_or(0.0); - } + let span = quant_span(disc_span(to_moon.y, size)); + let fade_w = crate::assets::quantize(seed.unwrap_or(0.0), 255.0); + crate::assets::write_gated( + &mut mats, + &mat.0, + |m| m.extension.span != span || m.extension.fade.w != fade_w, + |m| { + m.extension.span = span; + m.extension.fade.w = fade_w; + }, + ); } MoonPart::Glare => { // `2.0 × the moon size curve` world units (`[0xce9718]=2.0`; both view-lerp @@ -457,10 +510,15 @@ pub(super) fn follow_moons( // envelope (dnCurve × horizon × visibility) — Addendum #5's byte shape. let f = view_lerp(*cam_gt.forward(), to_moon); // × the weather seed (the glare alpha byte the pack modulates, Addendum #6). - let env = (0.1 + 0.9 * f) * env30 * seed.unwrap_or(1.0); - if let Some(m) = mats.get_mut(&mat.0) { - m.base.base_color = Color::srgba(tint[0], tint[1], tint[2], env); - } + let env = + crate::assets::quantize((0.1 + 0.9 * f) * env30 * seed.unwrap_or(1.0), 255.0); + let color = Color::srgba(tint[0], tint[1], tint[2], env); + crate::assets::write_gated( + &mut mats, + &mat.0, + |m| m.base.base_color != color, + |m| m.base.base_color = color, + ); } } // Propagation already ran this frame — the direct global write is what renders. @@ -510,11 +568,21 @@ pub(super) fn follow_stars( tf.scale = Vec3::splat(far * 0.88); // Propagation already ran this frame — the direct global write is what renders. *gt = GlobalTransform::from(*tf); - if let Some(m) = star_mats.get_mut(&mat.0) { - // White dots; global fade × the patch's authored weight, multiplied onto the texture's - // per-dot alpha, then blended gamma-correctly by star.wgsl. - m.base.base_color = Color::srgba(1.0, 1.0, 1.0, global * dome.weight); - } + // White dots; global fade × the patch's authored weight, multiplied onto the texture's + // per-dot alpha, then blended gamma-correctly by star.wgsl. The vertex alpha is a byte + // in the reference, so the product gates at 1/255 like the rest of the family. + let color = Color::srgba( + 1.0, + 1.0, + 1.0, + crate::assets::quantize(global * dome.weight, 255.0), + ); + crate::assets::write_gated( + &mut star_mats, + &mat.0, + |m| m.base.base_color != color, + |m| m.base.base_color = color, + ); } } diff --git a/crates/benilla/src/sun/materials.rs b/crates/benilla/src/sun/materials.rs index 8413edd3..f66a21b5 100644 --- a/crates/benilla/src/sun/materials.rs +++ b/crates/benilla/src/sun/materials.rs @@ -16,7 +16,7 @@ use bevy::shader::ShaderRef; /// `AlphaMode::Add` — the reference's SRC_ALPHA, ONE lens-flare blend (`0x7e5a16`), which is what lets /// the flare saturate the sun's core to white (decision 0502; plain linear `Add` under-weighted every /// mid-tone — the "too yellow" sun). -pub(super) type CelestialMaterial = ExtendedMaterial; +pub(crate) type CelestialMaterial = ExtendedMaterial; /// Per-material control. `fade.x` = the horizon alpha-ramp scale `k` (in sin-elevation terms; /// [`DISC_HORIZON_FADE`] for all three discs — the binary's 0.4-unit band at its radius-12 disc, @@ -36,7 +36,7 @@ pub(super) type CelestialMaterial = ExtendedMaterial; +pub(crate) type StarMaterial = ExtendedMaterial; /// Empty material extension — the star fragment needs no per-material uniforms (it reads the base-colour /// alpha = dot alpha × the star-curve global alpha). #[derive(Asset, AsBindGroup, Clone, TypePath, Default)] -pub(super) struct StarExt {} +pub(crate) struct StarExt {} impl MaterialExtension for StarExt { fn fragment_shader() -> ShaderRef { diff --git a/crates/benilla/src/sun/mod.rs b/crates/benilla/src/sun/mod.rs index d65ac042..17113bdc 100644 --- a/crates/benilla/src/sun/mod.rs +++ b/crates/benilla/src/sun/mod.rs @@ -55,7 +55,7 @@ mod mesh; mod setup; use follow::{follow_moons, follow_stars, follow_sun}; -use materials::{CelestialMaterial, StarMaterial}; +pub(crate) use materials::{CelestialMaterial, StarMaterial}; use setup::setup_sun; /// A sun billboard sprite — the disc or its additive lens-flare glare. `SunPart` selects which. diff --git a/crates/benilla/src/terrain_stream.rs b/crates/benilla/src/terrain_stream.rs index 0ad1a855..48256b49 100644 --- a/crates/benilla/src/terrain_stream.rs +++ b/crates/benilla/src/terrain_stream.rs @@ -324,6 +324,11 @@ fn stream_terrain( if std::mem::take(&mut state.global_wmo) { release_placement(&mut commands, placements, GLOBAL_WMO_UID); } + // The material dedup goes with the placements it deduped for — its strong handles are + // what kept every previous map's materials (and their textures) resident forever (the + // #bugs teleport leak). Cleared here, not on `world_map::MapChange`, so it shares the + // exact trigger of the teardown it belongs to. + placements.materials.clear(); state.map_dir = Some(dir.clone()); state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt"))); state.wdt_ungated = false; diff --git a/crates/benilla/src/ui_pass.rs b/crates/benilla/src/ui_pass.rs index 206aa8f2..7421084f 100644 --- a/crates/benilla/src/ui_pass.rs +++ b/crates/benilla/src/ui_pass.rs @@ -268,7 +268,7 @@ struct UiWhiteTexture(Handle); /// pipeline (`ui_quad.wgsl`) so straight-alpha and WoW-ADD quads share the pipeline and differ /// only per-material. Vertex colors carry the per-quad tint. #[derive(Asset, TypePath, AsBindGroup, Clone)] -struct UiQuadMaterial { +pub(crate) struct UiQuadMaterial { #[uniform(0)] additive: u32, #[texture(1)] diff --git a/crates/benilla/src/ui_script/extract/mod.rs b/crates/benilla/src/ui_script/extract/mod.rs index b6bad288..9691d656 100644 --- a/crates/benilla/src/ui_script/extract/mod.rs +++ b/crates/benilla/src/ui_script/extract/mod.rs @@ -64,9 +64,19 @@ pub(super) fn drive_script( // way in (input.rs), and measures ÷s on the way back. At a 768-tall window with the dial at // 1 the whole pipeline is bit-identical to the pre-virtual behavior. let s = super::seam_scale(h, ui_scale.0); - script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 }); - script.tick(time.delta_secs()); - script.resolve(); + // Phase spans (visible under `bevy/trace_chrome`): this system is the biggest flat CPU cost + // on an idle frame, and the ledger can only rank what has a name — tick (Lua OnUpdate), + // resolve (layout), measure (the text round-trips), extract (tree walk + rasterize), diff. + { + let _span = bevy::log::info_span!("ui_script: tick").entered(); + script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 }); + script.tick(time.delta_secs()); + } + { + let _span = bevy::log::info_span!("ui_script: resolve").entered(); + script.resolve(); + } + let measure_span = bevy::log::info_span!("ui_script: measure").entered(); // The digit-advance feed (the synchronous half of the money layout's metrics): measure // NumberFontNormal's '0'..'9' once per atlas scale and push them as `BENILLA_DIGIT_W`, so // `BenillaMoney_Set` can size its coin slots to their numbers *inside* one update — the real @@ -162,6 +172,7 @@ pub(super) fn drive_script( script.set_message_line_rows(&rows); } } + drop(measure_span); for err in script.take_errors() { warn!("ui_script: {err}"); } @@ -169,6 +180,7 @@ pub(super) fn drive_script( warn!("ui_script: {w}"); } + let extract_span = bevy::log::info_span!("ui_script: extract").entered(); let mut out = Vec::new(); let mut assets = world_assets; let mut minimap_slot = None; @@ -461,7 +473,9 @@ pub(super) fn drive_script( // Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing); // `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole. minimap_widget.0 = minimap_slot; + drop(extract_span); + let _span = bevy::log::info_span!("ui_script: diff").entered(); if quads.quads != out { quads.quads = out; quads.dirty = true; diff --git a/crates/benilla/src/world_map.rs b/crates/benilla/src/world_map.rs index 2d2c4ca6..3131ba1a 100644 --- a/crates/benilla/src/world_map.rs +++ b/crates/benilla/src/world_map.rs @@ -27,6 +27,17 @@ pub(crate) struct CurrentMap(pub(crate) u32); #[derive(Resource)] pub(crate) struct MapCatalogRes(pub(crate) MapCatalog); +/// A cross-map transition, observed as a [`CurrentMap`] flip — the **world-scope teardown +/// signal**. Every module that dedups map-scoped assets behind a strong-handle cache reads this +/// and clears its own cache (the #bugs teleport leak: those caches pinned every map ever visited, +/// and each retained uv/tint-animated material re-uploaded per frame, forever). A clear is always +/// safe mid-session — live users hold handle clones, so it only drops the *dedup*; the assets die +/// when their last user despawns, and the next spawn rebuilds under the loading screen. Carries +/// no payload: every evictor clears unconditionally, and [`announce_map_change`] logs the +/// transition itself. +#[derive(Message, Clone, Copy)] +pub(crate) struct MapChange; + /// Startup set the world-map catalog loads in, so the terrain/WDL streamers can order their own setup /// after it (they read [`MapCatalogRes`]/[`CurrentMap`] the moment they initialize). #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -38,10 +49,32 @@ pub(crate) struct WorldMapPlugin; impl Plugin for WorldMapPlugin { fn build(&self, app: &mut App) { - app.add_systems( + app.add_message::().add_systems( Startup, load_world_map.after(AssetSet::Open).in_set(WorldMapLoad), ); + app.add_systems(Update, announce_map_change); + } +} + +/// Announce every [`CurrentMap`] flip as a [`MapChange`]. The `Local` holds the last *announced* +/// map, so the startup seed (the first observation) announces nothing — there is no old map to +/// tear down. Watching the resource rather than the worldport wire keeps one definition of +/// "the map changed", and a same-map worldport (an instance re-enter) correctly stays silent. +fn announce_map_change( + map: Option>, + mut last: Local>, + mut changes: MessageWriter, +) { + let Some(map) = map else { return }; + if let Some(prev) = last.replace(map.0) { + if prev != map.0 { + info!( + "map change: {prev} → {} — evicting map-scoped caches", + map.0 + ); + changes.write(MapChange); + } } }