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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user