the teleport tour leak dies: a map you leave actually leaves

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-28 21:55:34 +07:00
co-authored by Claude Fable 5
parent 06e9457582
commit 39df11ee64
20 changed files with 546 additions and 61 deletions
+60
View File
@@ -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<M: bevy::asset::Asset>(
assets: &mut Assets<M>,
handle: &Handle<M>,
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<crate::world_map::MapChange>,
assets: Option<ResMut<WorldAssets>>,
) {
if changes.is_empty() {
return;
}
changes.clear();
if let Some(mut a) = assets {
a.textures.clear();
a.model_materials.clear();
}
}
+143 -2
View File
@@ -417,7 +417,30 @@ impl Plugin for LiveFpsPlugin {
samples: Vec::new(),
cpu_at_start: None,
})
.add_systems(Update, drive_live_fps);
.init_resource::<ChurnCensus>()
.add_systems(Update, drive_live_fps)
.add_systems(
First,
(
(
churn_counter::<bevy::image::Image>("image"),
churn_counter::<Mesh>("mesh"),
churn_counter::<StandardMaterial>("std"),
churn_counter::<crate::terrain::TerrainMaterial>("terrain"),
churn_counter::<crate::terrain::WowModelMaterial>("model"),
churn_counter::<crate::terrain::WdlMaterial>("wdl"),
churn_counter::<crate::terrain::LiquidMaterial>("liquid"),
),
(
churn_counter::<crate::sky::SkyMaterial>("sky"),
churn_counter::<crate::particles::WowParticleMaterial>("particle"),
churn_counter::<crate::sun::CelestialMaterial>("celestial"),
churn_counter::<crate::sun::StarMaterial>("star"),
churn_counter::<crate::clouds::CloudMaterial>("cloud"),
churn_counter::<crate::ui_pass::UiQuadMaterial>("uiquad"),
),
),
);
}
}
@@ -443,6 +466,97 @@ struct LiveFps {
cpu_at_start: Option<f64>,
}
/// 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<crate::terrain::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
models: Res<'w, Assets<benilla_assets::M2Model>>,
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<A: bevy::asset::Asset>(
label: &'static str,
) -> impl FnMut(MessageReader<bevy::asset::AssetEvent<A>>, ResMut<ChurnCensus>) {
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 `<unpathed>` 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<String> = Vec::new();
let mut unpathed = [0usize; 3];
for (kind, ids) in [
(
"image",
self.images.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"mesh",
self.meshes.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"model",
self.models.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
] {
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 <unpathed> 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<Res<crate::rig_palette::RigPalettes>>,
mut residency: ResidencyMeter,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut exit: MessageWriter<AppExit>,
) {
@@ -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::<Vec<_>>()
.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);
}
+2 -2
View File
@@ -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<StandardMaterial, CloudExt>;
pub(crate) type CloudMaterial = ExtendedMaterial<StandardMaterial, CloudExt>;
/// 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)]
+2
View File
@@ -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};
+17 -1
View File
@@ -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<crate::world_map::MapChange>,
geometry: Option<ResMut<ClutterGeometry>>,
) {
if changes.is_empty() {
return;
}
changes.clear();
if let Some(mut g) = geometry {
g.0.clear();
}
}
+43
View File
@@ -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<crate::world_map::MapChange>,
mut entity_mats: ResMut<EntityMaterials>,
mut composites: ResMut<SkinComposites>,
mut fx: ResMut<spell_fx::SpellFx>,
creatures: Option<ResMut<Creatures>>,
gos: Option<ResMut<GameObjects>>,
items: Option<ResMut<equipment::ItemDisplays>>,
) {
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::<MissileSound>()
.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
+19 -3
View File
@@ -419,10 +419,19 @@ pub(super) fn animate_liquid(
liquid: Option<Res<LiquidAssets>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
mut last_ticks: Local<Option<u32>>,
surfaces: Query<(), With<MeshMaterial3d<LiquidMaterial>>>,
) {
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,
);
}
}
+7
View File
@@ -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`.
+15
View File
@@ -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<crate::world_map::MapChange>,
mut plates: ResMut<Nameplates>,
) {
if changes.is_empty() {
return;
}
changes.clear();
plates.meshes.clear();
}
#[cfg(test)]
mod tests {
use super::*;
+17
View File
@@ -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<AssetEvent<WowModelMaterial>>,
mut booths: ResMut<BoothLight>,
) {
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,
+2
View File
@@ -284,6 +284,8 @@ impl Plugin for PortraitPlugin {
.init_resource::<BoothLight>()
.init_resource::<crate::ui_session::InteractNpc>()
.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).
+40 -12
View File
@@ -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<Vec4> = 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;
},
);
}
+18
View File
@@ -581,6 +581,23 @@ fn stop_all_channels(mut out: NonSendMut<SoundOutput>) {
}
}
/// 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<crate::world_map::MapChange>,
kits: Option<ResMut<SoundKits>>,
) {
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(
+99 -31
View File
@@ -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,
);
}
}
+4 -4
View File
@@ -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<StandardMaterial, CelestialExt>;
pub(crate) type CelestialMaterial = ExtendedMaterial<StandardMaterial, CelestialExt>;
/// 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<StandardMaterial, Celestial
/// clip, gamma add — the glares never route `0x6d1960`; their envelope gates them; `fade.w`/`span`
/// unused there).
#[derive(Asset, AsBindGroup, Clone, TypePath, Default)]
pub(super) struct CelestialExt {
pub(crate) struct CelestialExt {
#[uniform(100)]
pub(super) fade: Vec4,
/// The disc quad's vertical span in sin-elevation — `.x` = bottom edge, `.y` = top edge —
@@ -62,12 +62,12 @@ pub(super) const DISC_HORIZON_FADE: f32 = 30.0;
/// (`star.wgsl`) does a **gamma-correct premultiplied** blend so the soft white dots blend into the sky
/// like the reference, instead of over-brightening (our linear-space alpha blend would; see the shader
/// header). Used with `AlphaMode::Premultiplied`. `StarExt` carries no uniforms.
pub(super) type StarMaterial = ExtendedMaterial<StandardMaterial, StarExt>;
pub(crate) type StarMaterial = ExtendedMaterial<StandardMaterial, StarExt>;
/// 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 {
+1 -1
View File
@@ -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.
+5
View File
@@ -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;
+1 -1
View File
@@ -268,7 +268,7 @@ struct UiWhiteTexture(Handle<Image>);
/// 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)]
+17 -3
View File
@@ -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;
+34 -1
View File
@@ -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::<MapChange>().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<Res<CurrentMap>>,
mut last: Local<Option<u32>>,
mut changes: MessageWriter<MapChange>,
) {
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);
}
}
}