no world until a character is in one

- The login screen stops paying for a world nobody is in. It had been
  streaming and simulating all of Elwynn Forest behind a static image — the
  client began life as a terrain viewer and the streamers never had an off
  switch. The world now boots when you enter it, aimed at the map your
  character is actually on, and logging out releases it. Login frame cost:
  p50 18.6 to 10.3 cpu ms, worst paced frame 308 to 20 ms; entering
  Stratholme is a covered 1.2 s load.
- More login-screen honesty: the fullscreen glue image was re-uploaded to
  the GPU every frame — 22 MB a frame for a still picture — and the
  braziers' fire had been invisible since the 25th, simulating perfectly
  into a camera that is off, because two booths had computed the same render
  layer. Both fixed; the flames are back.
- Indoors got truer: the outdoor world draws only through the portal windows
  actually in frame, as the original does; the painted-skybox gate settled
  on the reference's real rule — any sky-flagged room the portal flood
  reaches; and interior objects — gates, hanging signs, chains — take their
  room's baked light, anchored at their box centre instead of an artist
  pivot that often sits under the floor.
- Hovering with a tooltip up takes its full fix: a tooltip content change
  used to run the whole-UI layout solve twice, and it runs once now, on top
  of the morning's fix that made an unchanged tooltip cost nothing at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-29 19:54:41 +07:00
co-authored by Claude Fable 5
parent 34fdbd1d16
commit 03aa04e54d
38 changed files with 2088 additions and 247 deletions
Generated
+12
View File
@@ -1435,6 +1435,7 @@ dependencies = [
"bevy_platform",
"bevy_utils",
"tracing",
"tracing-chrome",
"tracing-error",
"tracing-log",
"tracing-oslog",
@@ -6818,6 +6819,17 @@ dependencies = [
"syn",
]
[[package]]
name = "tracing-chrome"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0a738ed5d6450a9fb96e86a23ad808de2b727fd1394585da5cdd6788ffe724"
dependencies = [
"serde_json",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -294,11 +294,12 @@ enum Command {
prefix: Option<String>,
},
/// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's
/// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so
/// this is what *identifies* it — the bit turning out to be set on a group iff its root names
/// a skybox is the whole argument that it means "draw this root's skybox here". Also the
/// population instrument: which 1.12 buildings replace the `Light.dbc` gradient dome with an
/// authored sky (Stratholme's burning city is the headline), and how much of each does it
/// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so this
/// is what *identifies* it — across all 815 roots the bit never appears without a MOSB. Note
/// what that does and does NOT buy: it establishes `flag => MOSB`, never which group the
/// renderer tests (the carved law is the flood-VISITED group, decision 0773 correcting 0767).
/// Also the population instrument: which 1.12 buildings replace the `Light.dbc` gradient dome
/// with an authored sky (Stratholme's burning city is the only one reachable), and how much
Skyboxscan,
/// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a
/// time of day — the band-semantics instrument (which row holds which colour at which hour;
@@ -2771,16 +2771,22 @@ pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000`
/// ([`benilla_formats::WmoGroupInfo::show_skybox`]).
///
/// This is the instrument that *identifies* the flag. `0x40000` is not documented anywhere we can
/// cite, so its meaning is established by correlation: if the bit is set on a group **iff** that
/// group's root names a skybox, the bit is the per-group "draw the root's skybox here" gate and
/// nothing else. The summary prints that cross-tab, so the claim is re-checkable in one command
/// against the shipped files rather than trusted from a decision record.
/// This is the instrument that *identifies* the flag — and **exactly how far that identification
/// reaches is the point**. `0x40000` is undocumented, so the cross-tab is what establishes it means
/// anything at all: the bit never appears on a group whose root names no skybox, across all 815
/// roots. That is a one-way implication, `flag ⇒ MOSB`, and the summary prints it so the claim is
/// re-checkable in one command rather than trusted from a decision record.
///
/// **It does not, and cannot, say which group the RENDERER tests** — and reading it as if it did is
/// the mistake decision 0767 made (superseded by 0773). The carved law is that `0x40000` is tested
/// inside the portal flood (`0x6b42e0` in `0x6b41c0`) on the group being *visited*, so the predicate
/// is "any flood-reached group carries the bit". A census over static asset bytes has no way to see
/// that distinction; only the binary did.
///
/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the
/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it (Stratholme's
/// city shell sets the bit on its open streets — which are authored as INTERIOR groups — but not on
/// its enclosed rooms).
/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it. Stratholme's
/// city shell sets the bit on 61 of its 83 groups; the only other roots that set it at all are the
/// four Caverns of Time shells, which ship in the 5875 data with no 1.12 instance to enter.
pub fn skyboxscan(chain: &mut Chain) -> Result<()> {
// Roots only: a group file is `<stem>_NNN.wmo`, and only the root carries MOSB/MOGI.
let names: Vec<String> = chain
@@ -2848,17 +2854,22 @@ pub fn skyboxscan(chain: &mut Chain) -> Result<()> {
if flag_only == 0 {
println!(
"\n=> 0x40000 NEVER appears without a MOSB ({flag_only} counter-examples in {scanned} \
roots): the bit is the per-group 'draw this root's MOSB skybox' gate."
roots), and {mosb_only} root(s) name a skybox no group asks for. So the bit is real and \
both halves matter — but this census establishes only 'flag implies MOSB'."
);
println!(
" The converse does NOT hold ({mosb_only} root(s) name a skybox no group asks for), \
so the GATE IS THE FLAG, not the chunk — a renderer keyed off MOSB alone would show \
skies the reference never shows."
" It does NOT say WHICH group the renderer tests, and reading it as if it did is the \
mistake decision 0767 made (superseded by 0773). The carved law: 0x40000 is tested \
inside the portal flood (0x6b42e0 in 0x6b41c0) on the group being VISITED, never on the \
group the camera stands in — so the predicate is 'any FLOOD-REACHED group carries the \
bit, and the root names a MOSB'. Stratholme's King's Square is the counter-example that \
settles it: the camera's own group (39) is EXTERIOR and unflagged, and the reference \
paints the sky there anyway."
);
} else {
println!(
"\n=> {flag_only} group(s) set 0x40000 with no MOSB to draw — the flag is NOT the \
skybox gate; re-derive it before building on it."
"\n=> {flag_only} group(s) set 0x40000 with no MOSB to draw — that would break even the \
weak 'flag implies MOSB' reading this census rests on; re-derive before building on it."
);
}
Ok(())
+39
View File
@@ -109,6 +109,13 @@ impl InputFingerprint {
/// driven through real FrameXML + Lua) is a standing probe for a missed input or a missed touch;
/// set the env to extend that to a live run or another crate's suite. Read once; when off it
/// costs a relaxed atomic load per resolve.
/// `WOW_LAYOUT_PROF=1` — per-solve shape reporting (see the read site in [`UiScript::resolve_layout`]).
/// Read once; off, it costs one relaxed atomic load per resolve and no clock reads at all.
fn layout_prof_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_LAYOUT_PROF").as_deref() == Ok("1"))
}
fn layout_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
@@ -158,6 +165,7 @@ impl UiScript {
layout_fingerprint,
layout_epoch_resolved,
layout_solves,
layout_rounds,
..
} = model;
@@ -377,7 +385,16 @@ impl UiScript {
}
let round_cap = plan.len() + region_data.len() + 2;
// `WOW_LAYOUT_PROF=1` — the per-solve shape: rounds, the graph's size, and the split
// between the frame solve and the region sweep. The numbers that say whether a solve is
// expensive because it runs too often or because each pass walks the whole UI.
let prof = layout_prof_enabled();
let mut t_frames = std::time::Duration::ZERO;
let mut t_regions = std::time::Duration::ZERO;
let mut n_regions_swept = 0u64;
for round in 0..round_cap {
*layout_rounds += 1;
let t_round = prof.then(std::time::Instant::now);
let mut changed = false;
// Seed the solver: the screen root, then every region rect settled so far (regions are
@@ -425,8 +442,15 @@ impl UiScript {
//
// `scratch` is hoisted out of the loop so the per-region `LayoutInput` refills its
// anchors `Vec` in place instead of allocating one per region per round.
if let Some(t) = t_round {
t_frames += t.elapsed();
}
let t_reg = prof.then(std::time::Instant::now);
let mut scratch = LayoutInput::default();
for (&rh, data) in region_data.iter() {
if prof {
n_regions_swept += 1;
}
if data.anchors.is_empty() {
continue;
}
@@ -499,7 +523,22 @@ impl UiScript {
}
}
if let Some(t) = t_reg {
t_regions += t.elapsed();
}
if !changed {
if prof {
eprintln!(
"[layout-prof] rounds={} frames={} regions_total={} regions_swept={} \
frame_us={} region_us={}",
round + 1,
plan.len(),
region_data.len(),
n_regions_swept,
t_frames.as_micros(),
t_regions.as_micros()
);
}
if let Some((frames_before, regions_before)) = &verify_against {
assert!(
frames_before == resolved && regions_before == region_resolved,
+7
View File
@@ -588,6 +588,13 @@ impl UiScript {
self.model_ref().layout_solves
}
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
/// depth.
pub fn layout_rounds(&self) -> u64 {
self.model_ref().layout_rounds
}
/// A snapshot of the script errors collected so far (from `pcall`'d handlers).
pub fn errors(&self) -> Vec<String> {
self.model_ref().errors.clone()
+5
View File
@@ -57,6 +57,10 @@ pub(crate) struct Model {
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// How many fixpoint ROUNDS those solves ran in total. A solve's cost is rounds × the whole
/// graph, so this is what separates "the gate let too much through" from "each pass is doing
/// too much" — the two have different fixes.
pub(crate) layout_rounds: u64,
/// Hyperlink spans per frame — `(rect, link payload, full |H…|h markup)`, rects in the
/// engine's y-up screen space. Fed by the app each frame after it rasterizes message lines
/// ([`super::UiScript::set_link_spans`]); consumed by the pointer's release dispatch
@@ -644,6 +648,7 @@ impl Model {
layout_epoch: 0,
layout_epoch_resolved: None,
layout_solves: 0,
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
chat_tab: false,
+5
View File
@@ -10,6 +10,11 @@ description = "The Bevy app: terrain viewer -> integrated debug client"
# hot systems) + render-pass CPU timings to a Tracy capture (decision 0026 — the dev/player seam;
# a `docs/howto/running.md` this once cited never landed).
tracy = ["bevy/trace_tracy"]
# Opt-in Chrome-trace profiling: `cargo run --features trace_chrome` writes a `trace-<ts>.json` of
# every system's per-run span next to the working dir. Tracy's offline twin — it needs no GUI and no
# live capture, so a per-system cost question is answerable from a terminal (and from a run someone
# else recorded). Aggregate it with `scripts/tracesum.py`.
trace_chrome = ["bevy/trace_chrome"]
[dependencies]
anyhow.workspace = true
@@ -3,10 +3,15 @@
// the one place 1.12 content reaches this: `StratholmeSkybox.m2`, a static emissive cube whose three
// texture pairs are the painted red sky.
//
// GAMMA LANE (decision 0161): the model's batches are authored `emissive` + `two-sided` + opaque, so
// the faithful output is the texel's own gamma bytes, unlit and unmodulated — the same treatment the
// gradient dome's stops get. No fog: this IS the backdrop the world's fog converges toward, and the
// reference's sky pass runs before (and outside) the fog stage.
// GAMMA LANE (decision 0161): the batches are authored material flags `0x05` = UNLIT (`0x01`) |
// TWO_SIDED (`0x04`), blend 0 (`benilla-extract m2batch`), so the texel's own gamma bytes, unlit and
// unmodulated, is what the *material* asks for.
//
// UNVERIFIED, and flagged as such: we draw it **unfogged** — and the M2 does not set the UNFOGGED bit
// (`0x02`), so by its own authoring this model IS fogged. The no-fog treatment was an analogy with the
// gradient dome, not a byte law: wow-re has `CMapObj+0x12c` (the MOSB pointer) carved with no renderer
// behind it, so nothing yet says which pass this draws in or under what state. An RE dispatch is open
// on exactly that. Do not harden this into a claim before it lands.
//
// SKY-PASS DEPTH (see `sky_order.rs`, "The depth law"): the box is drawn camera-anchored at model
// scale, so its geometry sits *inside* the world — the shell radius must not decide occlusion.
+154
View File
@@ -0,0 +1,154 @@
//! `WOW_ASSET_CHURN=1` — what the app rewrites in `Assets<T>` every frame, and how big it is.
//!
//! The question this answers: Bevy's `prepare_assets<GpuImage>` re-uploads any image that was
//! **modified** since last frame, and `extract_render_asset` re-extracts it. Both showed up as a
//! flat multi-millisecond per-frame cost on the login screen — a static background image — where
//! nothing should be modified at all. A trace can name the *system* that pays; only the asset
//! events name the *asset* that made it pay.
//!
//! Prints one line per second: for each watched asset type, the per-frame Added/Modified counts,
//! and for images the megabytes those modifications ask the render world to re-upload, plus the
//! top offenders by how many frames they were touched in.
use std::collections::HashMap;
use bevy::asset::AssetEvent;
use bevy::prelude::*;
use bevy::time::Real;
pub(crate) struct AssetChurnPlugin;
/// Off unless `WOW_ASSET_CHURN=1`, read once (the plugin is skipped entirely, so an ordinary run
/// carries no systems at all — not even a disabled one).
pub(crate) fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_ASSET_CHURN").as_deref() == Ok("1"))
}
impl Plugin for AssetChurnPlugin {
fn build(&self, app: &mut App) {
if !enabled() {
return;
}
app.init_resource::<Churn>()
.add_systems(Update, (watch_images, watch_meshes, report).chain());
}
}
#[derive(Resource, Default)]
struct Churn {
frames: u32,
/// asset type -> (added, modified) event totals this window.
counts: HashMap<&'static str, (u64, u64)>,
/// Image id -> how many DISTINCT frames it was modified in, the last frame index that counted
/// (so several Modified events in one frame count once), and its byte size when last seen.
/// Counting events instead printed "touched in 77/9 frames" — a ratio above 1.0 is the
/// instrument telling you it is measuring something other than what its label says.
images: HashMap<AssetId<Image>, ImageChurn>,
/// Bytes of image data modification asks the render world to re-upload, this window.
image_bytes: u64,
last_report: f32,
}
/// One image's churn within the reporting window.
#[derive(Clone, Copy, Default)]
struct ImageChurn {
/// Distinct frames this image was modified in.
frames: u64,
/// The last frame index already counted, so N events in one frame count once.
last_frame: Option<u32>,
/// Its data size when last seen (what a modification asks to be re-uploaded).
bytes: usize,
}
fn watch_images(
mut events: MessageReader<AssetEvent<Image>>,
images: Res<Assets<Image>>,
mut churn: ResMut<Churn>,
) {
let churn = churn.as_mut();
for e in events.read() {
match *e {
AssetEvent::Added { .. } => churn.counts.entry("Image").or_default().0 += 1,
AssetEvent::Modified { id } => {
churn.counts.entry("Image").or_default().1 += 1;
let bytes = images
.get(id)
.map_or(0, |i| i.data.as_ref().map_or(0, Vec::len));
churn.image_bytes += bytes as u64;
let frame = churn.frames;
let slot = churn.images.entry(id).or_default();
if slot.last_frame != Some(frame) {
slot.last_frame = Some(frame);
slot.frames += 1;
}
slot.bytes = bytes;
}
_ => {}
}
}
}
fn watch_meshes(mut events: MessageReader<AssetEvent<Mesh>>, mut churn: ResMut<Churn>) {
for e in events.read() {
match *e {
AssetEvent::Added { .. } => churn.counts.entry("Mesh").or_default().0 += 1,
AssetEvent::Modified { .. } => churn.counts.entry("Mesh").or_default().1 += 1,
_ => {}
}
}
}
fn report(mut churn: ResMut<Churn>, time: Res<Time<Real>>, images: Res<Assets<Image>>) {
churn.frames += 1;
if time.elapsed_secs() - churn.last_report < 1.0 {
return;
}
let churn = churn.as_mut();
churn.last_report = time.elapsed_secs();
let frames = f64::from(churn.frames.max(1));
let mut line = format!("[asset-churn] over {} frames:", churn.frames);
let mut kinds: Vec<_> = churn.counts.iter().collect();
kinds.sort_by_key(|(k, _)| *k);
for (kind, (added, modified)) in kinds {
line.push_str(&format!(
" {kind}: +{:.1}/frame ~{:.1}/frame",
*added as f64 / frames,
*modified as f64 / frames
));
}
line.push_str(&format!(
" · image re-upload {:.2} MB/frame",
churn.image_bytes as f64 / frames / (1024.0 * 1024.0)
));
info!("{line}");
// The offenders: an asset touched in most frames is a per-frame rewrite, whatever its size.
let mut top: Vec<_> = churn.images.iter().map(|(id, v)| (*id, *v)).collect();
top.sort_by_key(|(_, c)| std::cmp::Reverse((c.frames, c.bytes)));
for (
id,
ImageChurn {
frames: n, bytes, ..
},
) in top.into_iter().take(6)
{
let dims = images.get(id).map_or((0, 0), |i| {
(
i.texture_descriptor.size.width,
i.texture_descriptor.size.height,
)
});
info!(
"[asset-churn] image {id:?} touched in {n}/{} frames · {}x{} · {:.2} MB",
churn.frames,
dims.0,
dims.1,
bytes as f64 / (1024.0 * 1024.0)
);
}
churn.frames = 0;
churn.counts.clear();
churn.images.clear();
churn.image_bytes = 0;
}
+52 -6
View File
@@ -146,9 +146,25 @@ impl Roster {
/// roster at the entry edge, before the server's `SMSG_LOGIN_VERIFY_WORLD` snap lands
/// (decision 0737).
pub(crate) fn pending_map(&self) -> Option<u32> {
self.pending_row().map(|c| c.map)
}
/// The picked character's `(map, wow xyz)` — **where the world we are about to load actually
/// is**, known from the roster row a whole server round-trip before `SMSG_LOGIN_VERIFY_WORLD`
/// says so. The streamers aim at this during world entry (decision 0777); without it the only
/// answer available before the snap is the hardcoded Northshire anchor
/// ([`crate::SPAWN_XY`]), which is a guess that is wrong for every character who isn't a fresh
/// human — and a wrong guess here does not merely idle, it spends the entry's IO budget
/// decoding tiles that are dropped a moment later.
pub(crate) fn pending_entry(&self) -> Option<(u32, [f32; 3])> {
self.pending_row()
.map(|c| (c.map, [c.position.x, c.position.y, c.position.z]))
}
/// The roster row for the pick in flight.
fn pending_row(&self) -> Option<&Character> {
self.pending_pick
.and_then(|g| self.chars.iter().find(|c| c.guid == g))
.map(|c| c.map)
}
}
@@ -306,12 +322,21 @@ fn debug_glue_roundtrip(
/// The logout-boundary smoke (`WOW_LOGOUT_SMOKE=1`, meant with the `WOW_CHAR` fast path): once
/// seated in the world, linger, request the `/logout` round-trip, confirm the return to
/// CharSelect, linger again (the glue theme should be the only thing audible over the logs'
/// world-teardown lines), and exit — the world-audio boundary is provable end-to-end without a
/// hand on the keyboard. Inert without the env.
/// world-teardown lines), **enter the world a second time**, and exit. Inert without the env.
///
/// The re-entry leg is not decoration. Since decision 0777 the world is *released* on the way out
/// (`terrain_stream::release_world`), so the second entry is a materially different code path from
/// the first — it streams a map the streamer has already torn down once — and a teardown that
/// forgets to reset its own bookkeeping fails exactly here and nowhere else. The world-audio
/// boundary this smoke was written for is still checked on the way through.
#[allow(clippy::too_many_arguments)] // a smoke test that drives the whole round trip
fn debug_logout_smoke(
state: Res<State<ClientState>>,
player: Res<crate::player::Player>,
commands: Res<crate::net::NetCommands>,
mut roster: ResMut<Roster>,
pick: Res<CharPick>,
streamer: Res<crate::terrain_stream::TerrainStreamer>,
time: Res<Time>,
mut exit: MessageWriter<AppExit>,
mut phase: Local<u8>,
@@ -332,13 +357,34 @@ fn debug_logout_smoke(
*phase = 2;
}
2 if *state.get() == ClientState::CharSelect => {
info!("logout-smoke: back at character select — lingering");
info!(
"logout-smoke: back at character select — {} tiles resident (must be 0)",
streamer.residency().1
);
(*phase, *mark) = (3, now);
}
3 if now - *mark > 4.0 => {
3 if now - *mark > 4.0 => match roster.chars.first().map(|c| (c.guid, c.name.clone())) {
Some((guid, name)) => {
info!("logout-smoke: re-entering the world as {name}");
send_pick(&mut roster, &pick, guid);
(*phase, *mark) = (4, now);
}
None => {
warn!("logout-smoke: empty roster — cannot test re-entry");
*phase = 5;
}
},
4 if *state.get() == ClientState::InWorld && player.active && now - *mark > 3.0 => {
info!(
"logout-smoke: re-entered — {} tiles resident, done",
streamer.residency().1
);
*phase = 5;
}
5 => {
info!("logout-smoke: done");
exit.write(AppExit::Success);
*phase = 4;
*phase = 6;
}
_ => {}
}
+7 -1
View File
@@ -177,12 +177,18 @@ pub(super) fn setup_cloud_layer(
/// The sky-dome visibility toggle (the clouds hide with the rest of the sky). All color state
/// lives in the texture — the kernel's `0x6cfb00` port owns it.
///
/// A WMO skybox ([`crate::wmo_sky`]) hides the clouds too: `CSky::Render`'s one shared boolean skips
/// **all six** sky elements together (`0x6d4a3b test edi,edi; je`), so the painted art is the whole
/// sky rather than a backdrop the procedural clouds keep drawing over. The art carries its own cloud
/// banks; layering ours on top lifted the painted zenith out of its near-black.
pub(super) fn apply_cloud_visibility(
debug: Res<DebugState>,
wmo_skybox: Res<crate::wmo_sky::CameraWmoSkybox>,
mut dome: Query<&mut Visibility, With<CloudDome>>,
) {
if let Ok(mut vis) = dome.single_mut() {
*vis = if debug.lighting.disable_sky_dome {
*vis = if debug.lighting.disable_sky_dome || wmo_skybox.0.is_some() {
Visibility::Hidden
} else {
Visibility::Inherited
+9 -1
View File
@@ -72,7 +72,15 @@ impl Plugin for CloudsPlugin {
app.add_plugins(MaterialPlugin::<layer::CloudMaterial>::default())
.init_resource::<CloudCoverage>()
.add_systems(Startup, layer::setup_cloud_layer.after(AssetSet::Open))
.add_systems(Update, (tick_clouds, layer::apply_cloud_visibility))
.add_systems(
Update,
(
tick_clouds,
// After the resolve: the dome and the painted skybox must agree WITHIN a frame,
// or one frame draws both (the ordering `crate::sky`'s gate already takes).
layer::apply_cloud_visibility.after(crate::wmo_sky::WmoSkyResolve),
),
)
// Camera-anchored placement post-propagation, like the sky dome (decision 0504).
.add_systems(
PostUpdate,
+37
View File
@@ -37,6 +37,10 @@ pub(super) struct WorldReadout<'w, 's> {
area: Res<'w, crate::terrain_stream::CurrentArea>,
areas: Option<Res<'w, crate::area::AreaTableRes>>,
interior: Res<'w, crate::wmo_portal::CurrentAreaInterior>,
/// The camera's own WMO room claim + the exterior-window worklist it produces — the two inputs
/// [`crate::exterior_cull`] runs on. See the readout below for why they are worth a line.
room: Res<'w, crate::wmo_portal::CameraInteriorClaim>,
windows: Res<'w, crate::wmo_portal::ExteriorWindows>,
skybox: Res<'w, crate::wmo_sky::CameraWmoSkybox>,
streamer: Res<'w, crate::terrain_stream::TerrainStreamer>,
self_guid: Res<'w, crate::net::SelfGuid>,
@@ -108,6 +112,39 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
ui.label(egui::RichText::new(format!("leaf area {leaf}")).color(OVERLAY_TEXT_DIM));
}
// The exterior-scene gate, in the two terms that decide it (decision 0774): which WMO ROOM the
// camera's own down-ray claims, and how many portal windows that room's flood left onto the
// outdoor world. Terrain draws iff a window admits it, so "why can I still see the ground from
// in here?" has exactly three answers and this line says which:
// * `room —` — no claim at all. We are on the OUTSIDE leg and draw the whole exterior, which
// is correct *if* the reference is too (a `0x8`-flagged group claims nothing — Stratholme's
// entrance hall is EXTERIOR, and the reference draws the world there as well).
// * `windows N` with N large, or a window covering most of the screen — we are indoors and the
// doorway rects are too generous.
// * `windows 0` — sealed, nothing exterior may draw; terrain visible anyway means something
// is reaching the screen that is not tagged `ExteriorScene` (world WMO placements and
// open-world liquid are knowingly not gated yet).
// Two numbers is the whole diagnosis, and neither was readable from the chair before.
let room_line = match world.room.0 {
Some(claim) => format!("room g{:02}", claim.room.group),
None => "room —".to_string(),
};
let window_line = match &*world.windows {
crate::wmo_portal::ExteriorWindows::Unrestricted => "exterior unrestricted".to_string(),
crate::wmo_portal::ExteriorWindows::Windows(rects) => {
let widest = rects
.iter()
.map(|[x0, y0, x1, y1]| ((x1 - x0) * (y1 - y0)).max(0.0))
.fold(0.0f32, f32::max);
format!(
"exterior {} window(s) · widest {:.0}% of screen",
rects.len(),
widest / 4.0 * 100.0
)
}
};
ui.label(egui::RichText::new(format!("{room_line} · {window_line}")).color(OVERLAY_TEXT_DIM));
// Position + facing, raw WoW coords (what the wire and every probe speak); the tile the
// feet are on and how much of the stream window is spawned.
let Some(player) = world.player.as_ref().filter(|p| p.active) else {
+50 -25
View File
@@ -23,7 +23,7 @@ use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interact::WorldObject;
use crate::interior::{InteriorKind, InteriorLit};
use crate::interior::part_interior_lit;
use crate::lighting::SharedLightBuffer;
use crate::model_fade::{FadeMaterials, PendingAppearFade};
use crate::net::{NetEntity, ObjectStore};
@@ -463,6 +463,24 @@ pub(super) fn attach_entity_visuals(
commands
.entity(entity)
.insert(crate::interior::BodyBakeCenter(bake_center));
// The light node's ATTACH MODE — the reference's `[node+0x90]` bit 13, written once at
// node creation from the descriptor TYPEMASK (`0x613e10`/`0x670db0`) and dispatched at
// `0x6a86d0`: a GameObject attaches by CONTAINMENT (`0x6a8c10`), anchored at the world
// bounding-box centre; a unit/player by DOWN-RAY (`0x6a8a20`), anchored at the position.
// The kind can't change under a live display-id swap, but the remove arm keeps the two
// mutually exclusive by construction rather than by that argument (decision 0776).
match net.kind {
EntityKind::GameObject => {
commands
.entity(entity)
.insert(crate::interior::ContainmentAttach);
}
_ => {
commands
.entity(entity)
.remove::<crate::interior::ContainmentAttach>();
}
}
// Identity for the mouseover inspector (and, later, hover tooltips / targeting).
let object = WorldObject {
kind,
@@ -821,28 +839,19 @@ pub(super) fn attach_entity_visuals(
// and the classifier yields ([`RenderFade`]); once the fade latches the
// classifier reclaims the tag and the steady material. `mat_interior` (the
// interior-capable build) stays the gate for which parts classify at all.
if mat_interior.is_some() {
// Anchored at the unit root: every part shares the root's verdict, so a body
// never splits across the interior/exterior light laws. The indoor LAW is one
// for every entity M2 — unit, player, GameObject alike take the footprint-MOCV
// bake (the reference registers each with the same entity-node fill, wow-re
// `unit-m2-shader-light.md`, superseding 0315's class split); the matte ×1.0
// stays as the bake's miss fallback.
if let Some(lit) =
part_interior_lit(mat, mat_interior, mat_bake, bake_center, entity)
{
// `rig_tag` rides every whole-tag write here and below (decision 0720).
let tag =
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 });
// Anchored at the unit root: every part shares the root's verdict, so a
// body never splits across the interior/exterior light laws. The indoor
// LAW is one for every entity M2 — unit, player, GameObject alike take the
// footprint-MOCV bake (the reference registers each with the same
// entity-node fill, wow-re `unit-m2-shader-light.md`, superseding 0315's
// class split); the matte ×1.0 stays as the bake's miss fallback.
let lit_kind = match mat_bake {
Some(bake) => InteriorKind::Bake {
material: bake.clone(),
center: bake_center,
},
None => InteriorKind::Matte,
};
child.insert((
MeshTag(tag),
InteriorLit::new(lit_kind, mat.clone()),
crate::interior::ClassifiedBy(entity),
));
child.insert((MeshTag(tag), lit));
}
// The batch's **animated material alpha** (the verified combine's runtime half,
// wow-re `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency
@@ -903,15 +912,31 @@ pub(super) fn attach_entity_visuals(
object.clone(),
card_follow,
));
// A card takes its MODEL's indoor law, through the same constructor as the sibling
// meshes it was split out of (decision 0778). No char-slot variants are consulted:
// a body/hair/cape/skin-extra batch is never a billboard batch, so the slot lookup
// living past the `continue` above costs a card nothing. The rig field stays 0 —
// a card draws the static mesh whatever its host.
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
bake_center,
entity,
) {
card.insert((MeshTag(crate::mesh_tag::alpha_bits(1.0)), lit));
}
// A card shares its batch's per-sequence alpha loops (the billboard split copies
// them onto every group), sampled off the same unit clock as the mesh parts — a
// creature's eye glow is authored to vanish in the sequences that hide its eyes.
// A world-root card is not interior-classified, so its tag has one writer.
// The classifier composes into the same tag rather than stomping it
// (`write_part_law` rewrites only the interior bits), so the tag is seeded here
// just for the batches that classified out above.
if let Some(anim) = &part.alpha_anim {
card.insert((
crate::doodad_anim::MatAnim::following(anim.clone(), entity),
MeshTag(crate::mesh_tag::alpha_bits(1.0)),
));
card.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
if part.material_interior.is_none() {
card.insert(MeshTag(crate::mesh_tag::alpha_bits(1.0)));
}
}
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
+31 -22
View File
@@ -32,7 +32,7 @@ use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::creature_anim::{HandGrip, NockLatch, NockedAmmo, VisualSheath, Wielded};
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interior::InteriorLit;
use crate::interior::part_interior_lit;
use crate::items::Items;
use crate::model_fade::{
fade_alpha, join_unit_appear_fade, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade,
@@ -874,26 +874,21 @@ pub(super) fn attach_held_items(
// joining the unit's appear-fade the tag carries its alpha instead — the classifier
// yields via its own `Without<RenderFade>`/`Without<PendingAppearFade>` filter, same
// as a body part.
if part.material_interior.is_some() {
// Anchored at the WEARER's root: an equipped item M2 aliases its wearer's
// light collector by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` —
// wow-re `unit-light-combine-storm.md`), so it never runs its own
// classify/footprint at the carried position. The animating hand joint
// once anchored these, and a swing alone could trip the resample gate and
// split the shield's light from the body's (director-caught, 2026-07-13).
// The fold reference is the wearer's BODY centre for the same reason.
let lit_kind = match &part.material_interior_bake {
Some(bake) => crate::interior::InteriorKind::Bake {
material: bake.clone(),
center: body_center.map_or(dm.bake_center_local, |c| c.0),
},
None => crate::interior::InteriorKind::Matte,
};
child.insert((
MeshTag(crate::mesh_tag::alpha_bits(tag_alpha)),
InteriorLit::new(lit_kind, part.material.clone()),
crate::interior::ClassifiedBy(entity),
));
// Anchored at the WEARER's root: an equipped item M2 aliases its wearer's
// light collector by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` —
// wow-re `unit-light-combine-storm.md`), so it never runs its own
// classify/footprint at the carried position. The animating hand joint
// once anchored these, and a swing alone could trip the resample gate and
// split the shield's light from the body's (director-caught, 2026-07-13).
// The fold reference is the wearer's BODY centre for the same reason.
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
child.insert((MeshTag(crate::mesh_tag::alpha_bits(tag_alpha)), lit));
}
// `FadeMaterials` is persistent bookkeeping (self-avatar zoom fade, decision 0032's
// despawn-fade-out), not tied to whether *this* spawn happens to join an in-flight
@@ -926,7 +921,7 @@ pub(super) fn attach_held_items(
// gear change, so the card's lifecycle and frame both come for free (same owner
// contract as the item's emitters below).
for (info, part) in billboard_parts {
commands.spawn((
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
Transform::default(),
@@ -936,6 +931,20 @@ pub(super) fn attach_held_items(
},
BillboardCard::following(&info, root),
));
// Same interior-light membership the item's mesh parts get above, through the same
// constructor and anchored at the same WEARER (decision 0778) — so a held torch's
// glow card can never split from the arm holding it. Spawned at a steady alpha: a
// card joins no appear-fade today (it carries neither `RenderFade` nor
// `FadeMaterials`), and the classifier preserves the alpha field regardless.
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
card.insert((MeshTag(crate::mesh_tag::alpha_bits(1.0)), lit));
}
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
// owner-follow rider as doodad emitters). `root` sits at the attach offset under the
+253
View File
@@ -0,0 +1,253 @@
//! **The exterior scene draws only through doorways you can see.**
//!
//! Standing inside a WMO interior, the reference does not draw the outdoor world at large — it draws
//! it once per *portal window* left over by the interior portal flood, with the view frustum narrowed
//! to that window. We drew it unconditionally, which is why a hillside tree 200 yd outside Stratholme
//! showed through the city's walls (the director's report; wow-re
//! `system/terrain/scratch/interior-exterior-scene-cull.md`, decision 0774).
//!
//! ## The carved law
//!
//! The world-scene driver `0x681070` branches at `0x681101` on `[0xc7b748]`, the camera-containing map
//! object (0 = outdoors):
//!
//! - **Outside leg** (`0x6811ca`): one populate walk `0x682fa0` against the literal full-screen rect
//! `{0, 0, 1, 1}` — the ordinary frustum. [`ExteriorWindows::Unrestricted`].
//! - **Inside leg** (`0x681120`): the flood leaves a deferred window worklist (count `[0xcbe320]`,
//! records `0xcbe324`, stride `0x14`), which the driver `rep movsd`s into `0xc7cb7c` (`0x68118b`)
//! and then walks **once per window** (`0x682fa0`), frustum narrowed to that window's rect.
//! **`0x681199 jbe 0x681204` skips the entire walk when the count is zero** — a sealed room draws no
//! exterior at all.
//!
//! `0x682fa0` is the *only* producer for every exterior bucket — ADT terrain (`0x683bf0`), ADT doodads
//! (`0x683700`), the second placement walk (`0x683340`), world WMO placements (`0x6856c0`), all three
//! liquid layers (`0x683ab0`) and the far band (`0x683040`) — and each drain unlinks what it walks. So
//! "no window" really does mean "no exterior content", with no second path to leak through.
//!
//! **Units are exempt and must stay exempt.** A CGUnit rides ClntObjMgr → `0x481540` → `0x607da0` →
//! `0x710b90` into the M2Scene worklist, drained unconditionally at `0x483460`+`0x48368a`; that
//! function contains zero references to `[0xc7b748]`/`[0xcbe320]`. The reference submits outdoor mobs
//! from a sealed room and lets the building's own geometry z-reject them.
//!
//! ## The clip geometry
//!
//! Per window, the reference bilerps the camera's four global corner rays (`0xc7bcd8`) by the window's
//! rect into 8 corners, and `0x6865f0`→`0x686640` builds **6 planes** from them. So the clip volume is
//! a **sub-frustum whose cross-section is the portal's screen-space AABB** (accumulated along the
//! portal chain), *not* its polygon — and the test is **per object, whole AABB** (`0x682f40`), never
//! per primitive. There is no scissor rect and no user clip plane anywhere on the path: a doodad
//! straddling the doorway edge is drawn **whole**, and the silhouette comes from the interior geometry
//! in front of it.
//!
//! We build the same volume the cheap way: an NDC rect is a scale+offset on clip space, so
//! `sub_clip_from_world = rect_to_ndc * clip_from_world` and Bevy's own
//! [`Frustum::from_clip_from_world`] extracts the identical 6 planes. That keeps us on one
//! plane-extraction implementation instead of a private corner-ray port.
//!
//! Windows narrower than [`MIN_WINDOW_NDC`] in either axis are dropped, matching the reference's
//! `[0x8029d0]` reject.
//!
//! ## What is gated so far, and what is knowingly not
//!
//! **Gated:** ADT terrain tiles (`0x683bf0`) and ADT doodad placements (`0x683700`) — the two buckets
//! that produce the reported symptom, and the two whose entities have no other `Visibility` writer, so
//! this is their sole authority (decision 0025).
//!
//! **NOT yet gated, deliberately:** world **WMO placements** (`0x6856c0`) and open-world **liquid**
//! (`0x683ab0`). Both already have a visibility authority — WMO group submeshes are written by the
//! portal PVS apply, liquid by its own lane — and bolting a second writer onto the same entities is
//! precisely the two-authorities bug 0025 forbids. Folding them in means teaching *those* authorities
//! to consume [`ExteriorWindows`], which is a real design step and not a line to sneak in here. Until
//! then a distant building or lake still shows through a wall where the reference would hide it.
//!
//! **Deviation to be honest about:** the reference tests one AABB per *object*; a doodad placement has
//! no root entity in our graph, so we tag and test each **submesh**. Their union is the object, so the
//! only divergence is at a doorway's edge — a submesh entirely outside the window is dropped where the
//! reference would draw the doodad whole. Visible only as a partially-clipped prop in a doorway.
use bevy::camera::primitives::{Aabb, Frustum};
use bevy::camera::visibility::VisibilitySystems;
use bevy::prelude::*;
use crate::player::WorldCamera;
use crate::wmo_portal::{ExteriorWindows, Rect, WmoPvsSet};
/// A window narrower than this fraction of the screen in either axis is dropped — the reference's
/// `0x682fa0` test against `[0x8029d0]` (0.01 of a 0..1 screen; our rects are NDC, so twice that).
const MIN_WINDOW_NDC: f32 = 0.02;
/// Tag for a piece of the **exterior scene** — the content the window worklist gates. Put this on ADT
/// terrain tiles, ADT doodad placements, world WMO placements and open-world liquid.
///
/// **Never on units, players, or anything parented to a WMO group**: the former are exempt by the
/// carved law above, and the latter are already culled by the portal PVS
/// ([`crate::wmo_portal::WmoGroupVis`]) — double-gating them would blank building interiors.
#[derive(Component)]
pub struct ExteriorScene;
/// Ordering handle: [`ExteriorWindows`] is consumed and `Visibility` written after this set.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExteriorCullSet;
pub(crate) struct ExteriorCullPlugin;
impl Plugin for ExteriorCullPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
PostUpdate,
apply_exterior_cull
.in_set(ExteriorCullSet)
// The windows are written by the flood in `Update`; the cull reads them and must land
// before Bevy's own visibility pass consumes the result this same frame.
.after(WmoPvsSet)
.after(bevy::transform::TransformSystems::Propagate)
.before(VisibilitySystems::CheckVisibility),
);
}
}
/// The sub-frustum for one NDC window rect.
///
/// An NDC rect maps to the full screen by a scale+offset **on clip space** — `clip.x` scaled by
/// `2/(x1-x0)` plus `clip.w` times `-(x0+x1)/(x1-x0)`, and likewise in y — so pre-multiplying
/// `clip_from_world` by that matrix yields a projection whose 6 extracted planes bound exactly the
/// window's sub-frustum. Depth rows are untouched: the window narrows the view laterally and inherits
/// the camera's own near/far.
fn window_frustum(rect: Rect, clip_from_world: &Mat4) -> Option<Frustum> {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
if w < MIN_WINDOW_NDC || h < MIN_WINDOW_NDC {
return None;
}
// Column-major: `Mat4::from_cols` takes columns, so the `w`-column carries the offsets.
let rect_to_ndc = Mat4::from_cols(
Vec4::new(2.0 / w, 0.0, 0.0, 0.0),
Vec4::new(0.0, 2.0 / h, 0.0, 0.0),
Vec4::new(0.0, 0.0, 1.0, 0.0),
Vec4::new(-(x0 + x1) / w, -(y0 + y1) / h, 0.0, 1.0),
);
Some(Frustum::from_clip_from_world(
&(rect_to_ndc * *clip_from_world),
))
}
/// Hide every [`ExteriorScene`] object that no window admits.
fn apply_exterior_cull(
windows: Res<ExteriorWindows>,
cam: Query<(&GlobalTransform, &Projection), With<WorldCamera>>,
mut scene: Query<(&GlobalTransform, Option<&Aabb>, &mut Visibility), With<ExteriorScene>>,
) {
let set = |vis: &mut Visibility, target: Visibility| {
if *vis != target {
*vis = target;
}
};
let rects = match &*windows {
// Outside leg: the ordinary frustum is the window, so stand down entirely and let Bevy's own
// frustum cull do its job. Writing `Inherited` (not `Visible`) hands each object back to
// whatever else owns it.
ExteriorWindows::Unrestricted => {
for (_, _, mut vis) in &mut scene {
set(&mut vis, Visibility::Inherited);
}
return;
}
ExteriorWindows::Windows(rects) => rects,
};
let Some((cam_t, proj)) = cam.iter().next() else {
return; // no camera yet — leave last frame's verdict rather than blanking the world
};
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
let frusta: Vec<Frustum> = rects
.iter()
.filter_map(|r| window_frustum(*r, &clip_from_world))
.collect();
// Zero surviving windows is the sealed-room case and draws nothing — `0x681199`'s skip. This is
// the one branch that must NOT fail open: failing open here is the bug being fixed.
for (gt, aabb, mut vis) in &mut scene {
let admitted = match aabb {
// Whole-AABB, per object — the reference's `0x682f40`. Not per primitive: a doodad
// straddling the doorway edge draws whole, and the interior geometry cuts its silhouette.
Some(aabb) => {
let world_from_local = gt.affine();
frusta
.iter()
.any(|f| f.intersects_obb(aabb, &world_from_local, true, true))
}
// No Aabb yet (mesh still loading): admit it. A missing bound is a *timing* gap, not a
// visibility verdict, and blanking on it would flicker the world as tiles stream in.
None => true,
};
set(
&mut vis,
if admitted {
Visibility::Inherited
} else {
Visibility::Hidden
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::math::Affine3A;
/// A camera at the origin looking down −Z (Bevy's convention), 90° fov, square aspect.
fn clip_from_world() -> Mat4 {
Projection::Perspective(PerspectiveProjection {
fov: std::f32::consts::FRAC_PI_2,
aspect_ratio: 1.0,
near: 0.1,
far: 1000.0,
..default()
})
.get_clip_from_view()
}
/// A point admitted by a frustum? (A degenerate AABB at `p`, identity placement.)
fn admits(f: &Frustum, p: Vec3) -> bool {
let aabb = Aabb::from_min_max(p - Vec3::splat(0.01), p + Vec3::splat(0.01));
f.intersects_obb(&aabb, &Affine3A::IDENTITY, true, true)
}
/// The full-screen window must behave exactly like the ordinary view frustum — this is the
/// outside leg's `{0,0,1,1}` rect, and if it ever narrowed, standing outdoors would start
/// clipping the world.
#[test]
fn the_full_screen_window_is_the_whole_frustum() {
let f = window_frustum([-1.0, -1.0, 1.0, 1.0], &clip_from_world()).expect("full screen");
assert!(admits(&f, Vec3::new(0.0, 0.0, -10.0)), "straight ahead");
// At 90° fov the frustum edge is |x| = |z|; well inside it must pass, and behind must not.
assert!(admits(&f, Vec3::new(5.0, 0.0, -10.0)), "right of centre");
assert!(admits(&f, Vec3::new(-5.0, 0.0, -10.0)), "left of centre");
assert!(!admits(&f, Vec3::new(0.0, 0.0, 10.0)), "behind the eye");
}
/// The load-bearing property: a window covering only the RIGHT half of the screen must admit
/// what is on the right and reject what is on the left. This is the whole cull — a doorway on
/// one side of the view must not let the world in on the other.
#[test]
fn a_half_screen_window_rejects_the_other_half() {
let f = window_frustum([0.1, -1.0, 1.0, 1.0], &clip_from_world()).expect("right half");
assert!(admits(&f, Vec3::new(5.0, 0.0, -10.0)), "right: admitted");
assert!(
!admits(&f, Vec3::new(-5.0, 0.0, -10.0)),
"left: must be rejected — the window is on the right"
);
}
/// The reference rejects a window narrower than a hundredth of the screen (`[0x8029d0]`); a
/// collapsed rect must produce no frustum at all rather than a degenerate one whose planes are
/// NaN (which `intersects_obb` would answer arbitrarily).
#[test]
fn a_collapsed_window_is_dropped_not_degenerate() {
assert!(window_frustum([0.5, -1.0, 0.5, 1.0], &clip_from_world()).is_none());
assert!(window_frustum([-1.0, 0.2, 1.0, 0.2001], &clip_from_world()).is_none());
// Just over the threshold still builds.
assert!(
window_frustum([0.0, -1.0, MIN_WINDOW_NDC + 0.001, 1.0], &clip_from_world()).is_some()
);
}
}
+32 -1
View File
@@ -179,6 +179,15 @@ type GoCollisionReadout = (
Option<&'static crate::go_anim::GoAnim>,
);
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
/// under, and which attach found the room — "two identical GameObjects a few yards apart, one lit
/// like the room and one like the street" is the report that made this a card line rather than a
/// rebuild with `WOW_INTERIOR_LOG`.
type EntityLightReadout = (
&'static crate::interior::InteriorAnchor,
Has<crate::interior::ContainmentAttach>,
);
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
@@ -199,6 +208,7 @@ fn inspect_ui(
// The GameObject collision readout (decision 0763): whether a hull exists, whether it is
// currently disabled, and the client's stored state the gate reads.
Query<GoCollisionReadout>,
Query<EntityLightReadout>,
),
guids: Query<&crate::net::Guid>,
castings: Query<&crate::creature_anim::Casting>,
@@ -255,7 +265,7 @@ fn inspect_ui(
.entity
.and_then(|e| parents.get(e).ok())
.map(|c| c.parent());
let (stores, kinds, collision) = (&stores.0, &stores.1, &stores.2);
let (stores, kinds, collision, lit) = (&stores.0, &stores.1, &stores.2, &stores.3);
let store = net_entity.and_then(|p| stores.get(p).ok());
// The unit's server name through the query cache — asks on first hover, fills on a later frame
// (the same ask-once path the unit frames use).
@@ -372,6 +382,21 @@ fn inspect_ui(
None => format!("anim {base}"),
}
});
// The light lane + the attach that found it (decision 0776). Absent until the classifier has
// resolved the anchor once — a freshly streamed object shows no line rather than a wrong one.
let light_line = net_entity
.and_then(|p| lit.get(p).ok())
.map(|(anchor, containment)| {
format!(
"light {} · {} attach",
anchor.law_label(),
if containment {
"containment"
} else {
"down-ray"
}
)
});
// The lines shown in the card — also exactly what a left-click copies to the clipboard.
let mut lines = vec![format!("{:?}", obj.kind), obj.label.clone()];
@@ -402,6 +427,9 @@ fn inspect_ui(
if let Some(line) = &anim_line {
lines.push(line.clone());
}
if let Some(line) = &light_line {
lines.push(line.clone());
}
lines.push(format!("{:.1} yd away", mouseover.distance));
// The inspector owns left-click while armed (player::control suppresses left-orbit during inspect),
@@ -449,6 +477,9 @@ fn inspect_ui(
if let Some(line) = &anim_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
if let Some(line) = &light_line {
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
}
ui.label(
egui::RichText::new(format!("{:.1} yd away", mouseover.distance))
.color(OVERLAY_TEXT_DIM),
+253 -7
View File
@@ -15,9 +15,19 @@
//! The abbey capture's flat-lit characters were this state, not a character-path law; the pair
//! itself is NEVER indoor-modified. [`InteriorKind::Matte`] keeps it for bake-less parts.
//!
//! One SHADING law, **two ATTACHES.** The law above is type-blind; how a node *finds* its group is
//! not. `0x6a86d0` forks on `[node+0x90]` bit 13 (`6a8714 test ah,0x20`), written at node creation
//! from the descriptor TYPEMASK — units/players/doodads take the down-ray attach `0x6a8a20` from
//! the node POSITION, **GameObjects take the containment attach `0x6a8c10` from the node's world
//! bounding-box CENTRE** (`[node+0x5c]`), whose face query retries UPWARD on a miss. There is no
//! subclass test anywhere in the dispatch: the mode bit is the whole fork. We carry it as
//! [`ContainmentAttach`] on the anchor and as [`crate::wmo_portal::LightAttach`] through the
//! verdict. Decision 0776 — a GameObject's origin routinely sits at or below its own floor, and
//! sharing the unit down-ray put those objects outdoors in the middle of a building.
//!
//! Entities move and stream independently of their building, so [`classify_entity_interior`]
//! re-tests them against the placed WMOs. The indoor test is the client's own: a faces-only
//! down-ray from the position onto the placed groups' geometry
//! ray from the attach's anchor onto the placed groups' geometry
//! ([`crate::wmo_portal::indoor_verdict_at`] — the LIGHTING-class fork `[node+0xc]`, outdoor iff
//! the hit group's `MOGI & 0x48` — NOT the zone-text `[node+0x90]` bit-0 predicate, which keys on
//! `0x8` alone and so calls the `0x40`-only city street groups "indoors"; decision 0475 — and an
@@ -101,6 +111,44 @@ pub(crate) enum InteriorKind {
},
}
/// The interior-light membership of ONE M2 batch — the single place a spawn site decides whether
/// a batch joins its model's law, and under which [`InteriorKind`]. `None` classifies the batch
/// out entirely (a WMO-display part, which has no interior variant to swap to).
///
/// **Every batch of a model goes through this, cards included.** The reference has one light node
/// per object and every batch shades through the same node fill (wow-re `unit-m2-shader-light.md`);
/// a billboard BONE re-orients geometry, it does not re-route light. Our billboard batches spawn as
/// world ROOTS so the facing system can own their transform (0153) — an implementation detail that
/// must not reach the light, and which did while this policy was written out longhand at each spawn
/// site and one of them omitted it: a Stratholme hanging sign baked from the room it hangs in while
/// its own chain cards stayed on the exterior material and lit from the sky (decision 0778).
///
/// `anchor` is the model's NET ENTITY root for every caller — body mesh, held item, and card alike
/// — so a model can never split across the two light laws ([`BodyBakeCenter`] for why an item
/// aliases its wearer rather than folding from its own position). Each site still writes its own
/// [`MeshTag`](bevy::mesh::MeshTag): the rig slot and the fade alpha are that site's to seed, and
/// the classifier composes into them.
pub(crate) fn part_interior_lit(
exterior: &Handle<WowModelMaterial>,
interior: Option<&Handle<WowModelMaterial>>,
bake: Option<&Handle<WowModelMaterial>>,
center: Vec3,
anchor: Entity,
) -> Option<(InteriorLit, ClassifiedBy)> {
interior?;
let kind = match bake {
Some(material) => InteriorKind::Bake {
material: material.clone(),
center,
},
None => InteriorKind::Matte,
};
Some((
InteriorLit::new(kind, exterior.clone()),
ClassifiedBy(anchor),
))
}
/// The law a part currently renders under (`None` until first classified).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AppliedLaw {
@@ -117,9 +165,23 @@ enum AppliedLaw {
/// held items included. The reference has exactly one light node per unit; an equipped item M2
/// aliases the wearer's collector by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` — wow-re
/// `unit-light-combine-storm.md`), so an item never folds from its own carried position.
///
/// For a [`ContainmentAttach`] anchor it is also the **attach anchor** — the reference's
/// `[node+0x5c]`, the same world point (decision 0776).
#[derive(Component, Clone, Copy)]
pub(crate) struct BodyBakeCenter(pub(crate) Vec3);
/// This anchor's light node runs the **containment attach** (`0x6a8c10`), not the down-ray one —
/// the reference's `[node+0x90]` bit 13, set at node creation from the descriptor TYPEMASK
/// (`0x613e10`/`0x670db0`) and dispatched at `0x6a86d0`. In 1.12 exactly one object class carries
/// it: **GameObjects**. See [`crate::wmo_portal::LightAttach`] for the two lanes at the bytes, and
/// decision 0776 for what it corrects — a GameObject's origin is frequently at or below its own
/// floor (a Stratholme portcullis spawns 15 cm under the corridor slab), so the down-ray this lane
/// used to share left it outside the building on the outdoor light while its identical neighbour
/// two doors down baked from the same floor.
#[derive(Component)]
pub(crate) struct ContainmentAttach;
/// The part → anchor edge of the classifier's registry (0734): every [`InteriorLit`] part names
/// its NET ENTITY root here — body parts and held/equipped items alike (module docs — the
/// reference has one light node per unit and items alias it). Bevy's relationship hooks maintain
@@ -150,6 +212,21 @@ pub(crate) struct InteriorAnchor {
kind_bake: bool,
}
impl InteriorAnchor {
/// One line naming the lane this anchor's parts render under — the inspect card's light
/// readout (`crate::interact`). "Which lane is this object on?" was the exact question decision
/// 0776 was found by, and answering it took a rebuild with `WOW_INTERIOR_LOG` plus an offline
/// `WOW_LIGHT_AT` probe; on the card it is a hover. Reads "exterior", "interior day/night", or
/// "interior bake (probe N)".
pub(crate) fn law_label(&self) -> String {
match self.law {
AppliedLaw::Exterior => "exterior".into(),
AppliedLaw::Matte => "interior day/night".into(),
AppliedLaw::Bake(slot) => format!("interior bake (probe {slot})"),
}
}
}
/// Parts whose material/tag need re-authoring from their anchor's current law — the classifier's
/// convergence queue (0734), replacing the per-part sweep's repair duty. Fed by the
/// [`InteriorLit`] `on_add` hook (a fresh part joining a settled anchor), the fade-latch observer
@@ -307,6 +384,8 @@ pub(crate) fn classify_entity_interior(
&GlobalTransform,
&LitParts,
Option<&mut InteriorAnchor>,
Has<ContainmentAttach>,
Option<&BodyBakeCenter>,
)>,
mut nodes: Query<&mut crate::entity_shade::GroundShade>,
bake_states: Query<&BakeState>,
@@ -335,7 +414,7 @@ pub(crate) fn classify_entity_interior(
// Anchors that wanted a resolve but had every part fade-excluded — the appear-fade lockout.
let mut n_fade_blocked = 0usize;
let mut resolve_us = 0.0f32;
for (anchor, anchor_t, lit_parts, mut state) in &mut anchors {
for (anchor, anchor_t, lit_parts, mut state, containment, bake_center) in &mut anchors {
n_anchors += 1;
let pos = anchor_t.translation();
let had_state = state.is_some();
@@ -378,6 +457,7 @@ pub(crate) fn classify_entity_interior(
let seated = seats.get(anchor).ok().map(|s| s.0);
n_resolved += 1;
let _r = std::time::Instant::now();
let (attach, attach_at) = attach_anchor(containment, bake_center, anchor_t);
let law = resolve_anchor_law(
&mut commands,
&mut probes,
@@ -389,6 +469,8 @@ pub(crate) fn classify_entity_interior(
&mut nodes,
anchor,
anchor_t,
attach,
attach_at,
&kind,
seated,
);
@@ -421,16 +503,34 @@ pub(crate) fn classify_entity_interior(
}
// `WOW_INTERIOR_LOG=1`: print interior classifications — the live-probe instrument for
// "did this entity actually classify indoors, and under which law?". Scoped to interior
// verdicts (plus interior→exterior flips) so the world's exterior masses stay silent.
// verdicts (plus interior→exterior flips) so the world's exterior masses stay silent. The
// ATTACH and the point it probed are printed too (0776): a line that says only "exterior"
// can't be read without knowing which lane produced it, and the two lanes now probe
// different points.
if (law != AppliedLaw::Exterior || had_state)
&& std::env::var_os("WOW_INTERIOR_LOG").is_some()
{
// The PART COUNT is load-bearing, not decoration: a lane readout says which law the
// model took, never which of its batches actually joined. A billboard card that
// silently classified out is invisible to every other reading of this line — the
// Stratholme sign baked correctly *and* its chains lit from the sky, and the anchor
// log said only "INTERIOR bake" for weeks (decision 0778). Compare it against the
// model's batch count (`benilla-extract m2batch <model>`).
eprintln!(
"[interior] t {:.2} anchor {anchor:?} at ({:.1}, {:.1}, {:.1}) -> {}",
"[interior] t {:.2} anchor {anchor:?} ({} parts) at ({:.1}, {:.1}, {:.1}) {} probe \
({:.1}, {:.1}, {:.1}) -> {}",
time.elapsed_secs(),
lit_parts.len(),
pos.x,
pos.y,
pos.z,
match attach {
crate::wmo_portal::LightAttach::Containment => "containment",
crate::wmo_portal::LightAttach::DownRay => "down-ray",
},
attach_at.x,
attach_at.y,
attach_at.z,
match law {
AppliedLaw::Exterior => "exterior".to_string(),
AppliedLaw::Matte => "INTERIOR matte".to_string(),
@@ -460,7 +560,7 @@ pub(crate) fn classify_entity_interior(
let Ok(edge) = part_anchors.get(part) else {
continue; // despawned since enqueue
};
let Ok((_, _, _, mut state)) = anchors.get_mut(edge.0) else {
let Ok((_, _, _, mut state, ..)) = anchors.get_mut(edge.0) else {
continue;
};
let Some(state) = state.as_deref_mut() else {
@@ -555,6 +655,27 @@ fn write_part_law(
true
}
/// The attach and its anchor point — one choice, because `0x6a86d0`'s mode fork picks both the
/// routine and the field it reads: a GameObject attaches by CONTAINMENT from its world
/// bounding-box centre (`[node+0x5c]`), everything else DOWN-RAYS from its position
/// (`[node+0xa8]`). Decision 0776.
fn attach_anchor(
containment: bool,
bake_center: Option<&BodyBakeCenter>,
anchor_t: &GlobalTransform,
) -> (crate::wmo_portal::LightAttach, Vec3) {
use crate::wmo_portal::LightAttach;
match (containment, bake_center) {
(true, Some(BodyBakeCenter(center))) => {
(LightAttach::Containment, anchor_t.transform_point(*center))
}
// A containment anchor whose body model carries no bounds yet degrades to its origin — the
// same point the down-ray lane uses, never a wrong one.
(true, None) => (LightAttach::Containment, anchor_t.translation()),
(false, _) => (LightAttach::DownRay, anchor_t.translation()),
}
}
/// Resolve one anchor's indoor law: the down-ray verdict, the node's target/seed updates, and for
/// the Bake law the footprint fold into the anchor's OWNED probe slot. (The settled ramp-only
/// refold from the cached ray products lives in the caller's walk — this always rays.) `seated` —
@@ -576,11 +697,19 @@ fn resolve_anchor_law(
nodes: &mut Query<&mut crate::entity_shade::GroundShade>,
anchor: Entity,
anchor_t: &GlobalTransform,
attach: crate::wmo_portal::LightAttach,
attach_at: Vec3,
kind: &InteriorKind,
seated: Option<u16>,
) -> AppliedLaw {
let pos = anchor_t.translation();
let verdict = indoor_verdict_at(wmos, instances.iter(), streamer, adt_tiles, pos);
let verdict = indoor_verdict_at(
wmos,
instances.iter(),
streamer,
adt_tiles,
attach_at,
attach,
);
// Publish the outdoor GROUND kind to the node before the law resolves: standing on an
// outdoor-class WMO surface (street/deck/porch) forces the lit 2.5 target — the WMO-linked
// skip-shadow bit, byte-verified (0477/0480; `entity_shade` reads it).
@@ -847,6 +976,85 @@ mod tests {
);
}
/// Decision 0778: a model's BILLBOARD batch takes the same law as its mesh batches. The card
/// spawns as a world ROOT (the facing system owns its transform, 0153) rather than as a child
/// of the model, and that is the whole difference — it goes through the same
/// [`part_interior_lit`] and names the same anchor, so both converge on the same law. The bug
/// this pins: a Stratholme hanging sign baked from its room while its own chain cards, never
/// classified at all, stayed on the exterior material and lit from the sky.
///
/// Also pins the classify-OUT arm — a WMO-display part builds no interior variant and must not
/// join at all, or it would be relit off a material it doesn't have.
#[test]
fn a_models_billboard_card_takes_the_same_law_as_its_mesh_parts() {
use bevy::ecs::system::RunSystemOnce;
let mut world = classifier_world();
let generation = world.resource::<WmoResidency>().generation;
let slot = world
.resource_mut::<PropProbes>()
.alloc_owned([Vec4::ZERO; 7])
.unwrap();
let anchor = world
.spawn((
GlobalTransform::default(),
PropProbeSlot(slot),
InteriorAnchor {
law: AppliedLaw::Bake(slot),
last_pos: Vec3::ZERO,
generation,
kind_bake: true,
},
))
.id();
let bake = Handle::default();
let exterior = Handle::default();
let lit_for = |anchor| {
part_interior_lit(&exterior, Some(&exterior), Some(&bake), Vec3::ZERO, anchor)
.expect("an entity M2 batch always builds an interior variant")
};
// The mesh batch rides the model's tree; the card is a world root. Nothing else differs.
let mesh = world
.spawn((
lit_for(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(0),
))
.id();
world.entity_mut(anchor).add_child(mesh);
let card = world
.spawn((
lit_for(anchor),
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
MeshTag(crate::mesh_tag::alpha_bits(1.0)),
))
.id();
world.run_system_once(classify_entity_interior).unwrap();
assert_eq!(
world.get::<InteriorLit>(card).unwrap().applied,
Some(AppliedLaw::Bake(slot)),
"the world-root card takes its model's law, not the world's"
);
assert_eq!(
world.get::<InteriorLit>(mesh).unwrap().applied,
world.get::<InteriorLit>(card).unwrap().applied,
"a model never splits across the two light laws at the billboard seam"
);
assert_eq!(
world.get::<MeshTag>(card).unwrap().0,
crate::mesh_tag::with_interior_probe(crate::mesh_tag::alpha_bits(1.0), slot),
"the law composes into the tag — the probe slot lands, the card's alpha survives"
);
assert!(
part_interior_lit(&exterior, None, None, Vec3::ZERO, anchor).is_none(),
"a WMO-display part has no interior variant and classifies out entirely"
);
}
/// The mixed-kind hole (0734 §3): a bake-capable part joining an anchor whose law was
/// resolved from a matte-kind part drops the anchor's record — the next run re-rays with the
/// bake kind in reach instead of riding the matte fallback until the anchor happens to move.
@@ -1051,6 +1259,44 @@ mod tests {
);
}
/// **The 0776 fork.** A GameObject anchor probes at its world bounding-box CENTRE, a unit at
/// its position — and the centre is where the difference bites: the Stratholme portcullis whose
/// spawn z sits 15 cm *under* the corridor slab rays into open air from its origin and into the
/// room from its centre (measured: `exterior` at z 125.354, `BAKE g02` at 125.40 and above).
/// The scale leg matters too — the centre is model-local, so a scaled placement must carry it
/// through the transform rather than adding a raw offset.
#[test]
fn a_gameobject_anchors_at_its_box_centre_and_a_unit_at_its_position() {
use crate::wmo_portal::LightAttach;
let at = GlobalTransform::from(
Transform::from_translation(Vec3::new(10.0, 100.0, -5.0)).with_scale(Vec3::splat(2.0)),
);
let centre = BodyBakeCenter(Vec3::new(0.0, 3.5, 0.0));
let (attach, anchor) = attach_anchor(true, Some(&centre), &at);
assert_eq!(attach, LightAttach::Containment);
assert_eq!(
anchor,
Vec3::new(10.0, 107.0, -5.0),
"the containment anchor is the box centre through the placement — scale included"
);
let (attach, anchor) = attach_anchor(false, Some(&centre), &at);
assert_eq!(attach, LightAttach::DownRay);
assert_eq!(
anchor,
at.translation(),
"a unit still rays from its position, box centre or not"
);
// No bounds yet (the model is still streaming): degrade to the origin, never to a wrong
// point — the containment lane's other legs still apply.
let (attach, anchor) = attach_anchor(true, None, &at);
assert_eq!(attach, LightAttach::Containment);
assert_eq!(anchor, at.translation());
}
/// The teardown race, both arms: a slot seated on a live anchor swaps normally; one seated
/// on an anchor whose despawn applied first neither panics (the old crash) nor leaks the
/// freshly allocated slot (the pool never resets — an orphan would be held forever).
+18 -6
View File
@@ -17,8 +17,10 @@
//! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears
//! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*,
//! from the messages/resources the net bridge already publishes; a residency backstop catches any
//! path with no edge (and covers boot, which is what warms the world pipelines behind the glue —
//! decision 0540). It clears when the destination is **scene-presentable** ([`WorldLoadProgress`],
//! path with no edge, **in world only** — it used to fire at boot too, and that is what warmed the
//! world's pipelines behind the glue (0540), which only worked while a world was being streamed
//! there at all. Since 0777 none is, and the warm-up rides the entry cover instead. It clears when
//! the destination is **scene-presentable** ([`WorldLoadProgress`],
//! published by `terrain_stream` + the collider queue): every wanted tile spawned, the focus
//! neighbourhood's placements up, colliders quiet, the snap no longer awaited. Never anything
//! about the *body* — feet-on-ground is not a load condition (the flying-teleport hang, 0737).
@@ -424,10 +426,20 @@ fn drive_loading_screen(
}
// --- Backstop trigger: the ground under the view focus isn't resident and nothing raised us
// (a far same-map `.tele`, boot — which is what warms the world pipelines behind the glue,
// decision 0540 — or any path with no edge). Normal streaming keeps the focus tile resident,
// so this never fires while walking. ---
if !screen.active && !progress.focus_resident {
// (a far same-map `.tele`, or any path with no edge). Normal streaming keeps the focus tile
// resident, so this never fires while walking.
//
// **In world only** (decision 0777). It used to fire at boot as well, and that was load-bearing
// by accident: a raised screen keeps the world camera active (0540), which is what compiled the
// world's pipelines behind the glue. But it only worked because the world was being streamed
// behind the glue in the first place — with no world to load there, the same trigger would put
// an invisible screen up forever against residency that never arrives. The warm-up is not lost,
// it MOVED: the world now loads under the cover this same function raises on world entry, which
// is where a warm-up belongs. ---
if !screen.active
&& !progress.focus_resident
&& *state.get() == crate::char_select::ClientState::InWorld
{
screen.raise("focus not resident", false, None, now);
}
+12 -2
View File
@@ -10,8 +10,11 @@
//! In parallel a background thread ([`net`]) logs in (`$WOW_USER`/`$WOW_PASS`/`$WOW_HOST`, default
//! `one`/`pone`/`localhost`), enters the world, and streams object updates. NPCs and GameObjects
//! render as their real models (resolved from the display id via the creature/GameObject catalogs); other
//! players stay cyan cubes, and our own avatar is blue until we take third-person control of it. If the
//! server is down, the world still renders.
//! players stay cyan cubes, and our own avatar is blue until we take third-person control of it.
//! **The world is loaded when a character enters it, and released when they leave** — the glue
//! screens have no world behind them (decision 0777). With no server there is no world: the client
//! sits at the login screen, which is what the real one does. The scene harness (`$WOW_CAPTURE`)
//! boots straight in-world and is unaffected.
//!
//! Controls: WASD walks the avatar (Ctrl sprints); right-drag turns it, left-drag orbits the camera
//! (both hide/freeze the cursor while held), scroll wheel zooms; `F` toggles free-fly (then WASD flies
@@ -19,6 +22,7 @@
mod area;
mod area_trigger;
mod asset_churn;
mod assets;
mod bgwin;
mod billboard;
@@ -42,6 +46,7 @@ mod decal;
mod doodad_anim;
mod entities;
mod entity_shade;
mod exterior_cull;
mod ffx_glow;
mod glue;
mod glue_strings;
@@ -455,6 +460,8 @@ fn main() -> AppExit {
// After DebugPanelPlugin so the egui plugin/context it sets up already exists. Toggle: P.
.add_plugins(PerfPlugin)
.add_plugins(hover_log::HoverLogPlugin)
.add_plugins(asset_churn::AssetChurnPlugin)
.add_plugins(particles::census::plugin)
// Foundation: opens the patch chain + inserts WorldAssets/RenderConfig (AssetSet::Open), which
// every other subsystem's startup runs after.
.add_plugins(AssetPlugin)
@@ -483,6 +490,9 @@ fn main() -> AppExit {
// portals from the camera's group, so the Stormwind cathedral culls from the Trade District. Only
// computes the PVS; the Visibility authority (DebugPanelPlugin) applies it (decisions 0025/0031).
.add_plugins(WmoPortalPlugin)
// The exterior scene draws only through portal windows the flood left behind (decision 0774):
// from inside a building, terrain and ADT doodads are gated on the deferred window worklist.
.add_plugins(exterior_cull::ExteriorCullPlugin)
// Streamed world entities: cube assets + display catalogs at startup, sync each frame.
.add_plugins(EntitiesPlugin)
// Creature animation: pick Stand/Walk/Run from each creature's movement state each frame (Milestone C).
+1
View File
@@ -28,6 +28,7 @@ use benilla_formats::ParticleEmitterDef;
use bevy::prelude::*;
pub mod buffer;
pub(crate) mod census;
mod depthdump;
mod emit;
mod model;
+93
View File
@@ -0,0 +1,93 @@
//! `WOW_FX_CENSUS=1` — where this frame's particle draws are actually addressed, and whether the
//! view they name is switched on.
//!
//! Built to answer "the login screen's braziers are simulating but nothing is on screen." A draw
//! record names the **main-world camera entity** whose view it belongs to
//! ([`super::buffer::EffectDraw::cam`]), and `particles::sim` resolves that for a booth-layered
//! emitter by finding the first booth camera whose `RenderLayers` intersect the emitter's. If two
//! booths share a layer, the wrong camera can win — and if that camera is inactive, the emitter
//! simulates forever and never draws. Nothing about that is visible in a frame time, a trace, or
//! the emitter's own state: the particles are all *correct*, just addressed to a view nobody is
//! rendering. The census makes it a line of output (decision 0775).
//!
//! Prints every 2 s: frames, live vertex count, how many DISTINCT vertex-buffer states were seen
//! (the liveness check — a frozen sim repeats one state), and the per-camera draw histogram with
//! each camera's booth token and active flag.
use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::time::Real;
use super::buffer::EffectQuads;
use crate::portrait::BoothCam;
/// Off unless `WOW_FX_CENSUS=1` — the plugin adds no system at all otherwise.
pub(crate) fn plugin(app: &mut App) {
if std::env::var("WOW_FX_CENSUS").as_deref() != Ok("1") {
return;
}
app.add_systems(Update, census);
}
fn census(
cams: Query<(Entity, &Camera, Option<&BoothCam>)>,
quads: Option<Res<EffectQuads>>,
time: Res<Time<Real>>,
mut last: Local<f32>,
mut states: Local<HashSet<u64>>,
mut frames: Local<u32>,
) {
use std::hash::{Hash, Hasher};
let Some(quads) = quads else { return };
*frames += 1;
// A cheap fingerprint of the head of the vertex buffer: enough to tell "this frame differs
// from the last" without hashing megabytes.
let mut h = std::collections::hash_map::DefaultHasher::new();
quads.verts.len().hash(&mut h);
for v in quads.verts.iter().take(600) {
bytemuck::bytes_of(v).hash(&mut h);
}
states.insert(h.finish());
if time.elapsed_secs() - *last < 2.0 {
return;
}
*last = time.elapsed_secs();
let mut per_cam: HashMap<Entity, usize> = HashMap::new();
for d in &quads.draws {
*per_cam.entry(d.cam).or_default() += 1;
}
let mut rows: Vec<String> = per_cam
.iter()
.map(|(e, n)| {
let (token, active) =
cams.get(*e)
.map_or(("<despawned>".into(), false), |(_, c, b)| {
(
b.map_or_else(|| "world".to_string(), |b| format!("booth:{}", b.0)),
c.is_active,
)
});
format!(
"{n} → {token}{}",
if active {
" [ACTIVE]"
} else {
" [OFF — these draws go nowhere]"
}
)
})
.collect();
rows.sort();
info!(
"[fx-census] {} frames · {} verts · {} distinct buffer states · draws: {}",
*frames,
quads.verts.len(),
states.len(),
rows.join(", ")
);
*frames = 0;
states.clear();
}
+5 -1
View File
@@ -129,8 +129,12 @@ pub(super) fn setup_player(
/// screen — never behind the glue screens, where the streamed world (25 tiles, tens of thousands
/// of entities, MSAA 4×) otherwise renders unseen behind an opaque fullscreen glue scene every
/// frame. The loading-screen case is load-bearing: that covered render is what compiles the
/// world's pipelines at boot, so the first visible in-world frame doesn't hitch on shader builds.
/// world's pipelines, so the first visible in-world frame doesn't hitch on shader builds.
/// Capture runs boot straight `InWorld` (`CharSelectPlugin::start_in_world`) — always active there.
///
/// This gate stays deliberately WIDER than [`crate::schedule::world_is_live`], which decides
/// whether the world is *loaded* at all (decision 0777): the camera must also render while the
/// cover is up, which is exactly the window in which the world is streaming in.
pub(super) fn gate_world_camera(
state: Res<State<crate::char_select::ClientState>>,
loading: Res<crate::loading_screen::LoadingScreen>,
+74 -7
View File
@@ -29,13 +29,13 @@ use super::framing::{attachment_point, DIAG_TO_VERT};
use super::{
aim, body_frame, new_target_image, spawn_booth_model, Booth, BoothBillboardSpec, BoothCam,
BoothLight, BoothMotion, BoothPart, BoothRider, Booths, PortraitImages, PortraitSource,
PAPERDOLL_LAYER,
GLUE_LAYER,
};
/// The glue booth slot token (its key in [`super::PortraitImages`] / [`Booths`]).
pub(crate) const GLUE_SLOT: &str = "glue";
/// The glue booth's render layer — the next past the paper doll's.
const GLUE_LAYER: usize = PAPERDOLL_LAYER + 1;
// `GLUE_LAYER` is defined with the rest of the booth ladder in [`super`] — defining it here, from
// the same `PAPERDOLL_LAYER + 1` the inspect booth used, is exactly how the two collided.
/// The scene-less fallback target resolution — with a scene up the target resizes to the window
/// (the glue screens are fullscreen renders).
const GLUE_SIZE: u32 = 1024;
@@ -680,15 +680,26 @@ pub(super) fn sync_glue_scene(
/// Resize the booth's render target if it isn't already `w`×`h` (content is discarded — the next
/// frame repaints it whole).
fn resize_target(images: &mut Assets<Image>, target: &Handle<Image>, w: u32, h: u32) {
if let Some(image) = images.get_mut(target) {
if image.width() != w || image.height() != h {
// Through the house write-gate ([`crate::assets::write_gated`]), for the reason that helper
// exists: `Assets::get_mut` queues `AssetEvent::Modified` unconditionally — it cannot know
// whether the caller wrote anything — and a Modified image is re-extracted and re-uploaded
// whole by `prepare_assets<GpuImage>` next frame. This target is the glue scene's FULLSCREEN
// render target: at 3200×1800 it is 22 MB, and taking the borrow once per frame just to
// compare two u32s re-uploaded all 22 MB every frame on a screen where nothing moves
// (~1.3 GB/s; measured with `WOW_ASSET_CHURN=1`, decision 0772). This was the ONE asset write
// site in the tree not already behind that gate.
crate::assets::write_gated(
images,
target,
|image| image.width() != w || image.height() != h,
|image| {
image.resize(Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
});
}
}
},
);
}
/// Re-bake the glue booth when the assembled parts change (a dial moved, a different character
@@ -913,6 +924,62 @@ mod tests {
use benilla_assets::ModelLight;
use benilla_formats::M2Light;
/// A same-size `resize_target` must not touch the image at all. `Assets::get_mut` queues
/// `AssetEvent::Modified` whether or not the caller writes, and this target is the glue
/// scene's fullscreen render target — one needless borrow per frame re-uploaded 22 MB per
/// frame at 3200×1800 while the login screen sat still (decision 0772).
#[test]
fn a_same_size_resize_does_not_mark_the_target_modified() {
use bevy::asset::AssetEvent;
use bevy::image::Image;
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()))
.init_asset::<Image>();
let handle = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
images.add(Image::new_target_texture(
64,
64,
bevy::render::render_resource::TextureFormat::bevy_default(),
None,
))
};
// Drain the Added event from the insert above.
app.update();
let drain = |app: &mut App| {
let world = app.world_mut();
let mut events = world.resource_mut::<Messages<AssetEvent<Image>>>();
let n = events.drain().count();
n
};
drain(&mut app);
// Same size, ten frames: silence.
for _ in 0..10 {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
resize_target(&mut images, &handle, 64, 64);
}
app.update();
assert_eq!(
drain(&mut app),
0,
"a no-op resize must not queue AssetEvent::Modified — every one of those re-uploads \
the whole target to the GPU"
);
// A real size change still lands.
{
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
resize_target(&mut images, &handle, 128, 96);
}
app.update();
assert!(drain(&mut app) > 0, "a real resize must still be published");
let images = app.world().resource::<Assets<Image>>();
let image = images.get(&handle).expect("target still there");
assert_eq!((image.width(), image.height()), (128, 96));
}
fn light(
light_type: u16,
pos: [f32; 3],
+23 -1
View File
@@ -114,6 +114,27 @@ const PORTRAIT_LAYER_BASE: usize = 2;
const PAPERDOLL_LAYER: usize = PORTRAIT_LAYER_BASE + SLOTS.len();
/// The inspect booth's render layer — the next one past the paper doll's.
const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1;
/// The glue booth's render layer — the next one past inspect's. **Every booth layer is computed
/// HERE**, in one ladder, because they were not: the glue booth (`930b327c`) and the inspect booth
/// (`ead3b0c9`) each defined "the next layer past the paper doll's" in a different file and landed
/// on the same number. Two booths sharing a layer is not a cosmetic clash — `particles::sim`
/// resolves an emitter's booth camera by *finding the first camera whose layers intersect*, so the
/// glue scene's 28 emitters addressed the INSPECT camera, which is off at the glue screens, and the
/// login screen's braziers simulated forever without ever being drawn (decision 0775).
pub(super) const GLUE_LAYER: usize = INSPECT_LAYER + 1;
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
// and the emitter→camera match, and the failure above was silent in both.
const _: () = assert!(
PAPERDOLL_LAYER != INSPECT_LAYER
&& INSPECT_LAYER != GLUE_LAYER
&& PAPERDOLL_LAYER != GLUE_LAYER,
"booth render layers must be distinct — see GLUE_LAYER"
);
const _: () = assert!(
PAPERDOLL_LAYER > PORTRAIT_LAYER_BASE + SLOTS.len() - 1,
"booth layers must not overlap the per-slot portrait layers"
);
/// The baked image is high-res (vs the ref's 64²) — the crisp modern look. Square; the UI quad
/// shader cuts the inscribed circle at draw time (`ui_quad.wgsl`'s `circular`, the ref's stencil).
const PORTRAIT_SIZE: u32 = 256;
@@ -336,8 +357,9 @@ struct Booths(HashMap<String, Booth>);
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
/// (`BoothCam`, not `PortraitCamera` — that name is the authored M2 rig, `benilla_assets::PortraitCamera`.)
/// (`pub(crate)` for the particle census, which reports draws per booth token — decision 0775.)
#[derive(Component)]
struct BoothCam(String);
pub(crate) struct BoothCam(pub(crate) String);
/// Owns the portrait bake pipeline: the [`PortraitImages`] bridge + the per-slot off-screen booths.
pub(crate) struct PortraitPlugin;
+22
View File
@@ -18,6 +18,28 @@
use bevy::prelude::*;
use crate::char_select::ClientState;
/// **Is there a world?** — the run condition every world-*owning* subsystem gates on.
///
/// The world exists only while a character is in it. That reads as a truism and was, for most of
/// this project, false: benilla began as a terrain viewer, so the streamers ran from `Startup` and
/// the client loaded and simulated Elwynn Forest behind the login screen — 5095 tiles and tens of
/// thousands of entities, anchored on a hardcoded [`crate::SPAWN_XY`], for a character who might
/// be on another continent. 0540 gated the *camera* (the world was at least not drawn); 0772
/// measured what was left — 5.5 cpu ms/frame of streaming, visibility sweeps, palette uploads and
/// render-world prepare under a static background image, and a 638 ms worst frame — and left the
/// load itself open as a director's call. The call: **don't load the world until we have to.**
///
/// Consumers gate on this, not on `in_state(InWorld)` written out by hand, so "what counts as
/// having a world" has one definition to change. The camera's own gate
/// ([`crate::player::setup::gate_world_camera`]) is deliberately *wider* — it also renders under
/// an opaque loading screen, which is what compiles the world's pipelines before the first visible
/// frame.
pub(crate) fn world_is_live(state: Res<State<ClientState>>) -> bool {
*state.get() == ClientState::InWorld
}
/// Ordered stages of the per-frame world-transition pipeline (configured `.chain()`ed in `Update`).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum WorldStage {
+57
View File
@@ -124,6 +124,63 @@ impl Plugin for SunPlugin {
.add_systems(
PostUpdate,
(follow_sun, follow_moons, follow_stars).in_set(crate::billboard::BillboardPlace),
)
// The WMO-skybox suppression, ordered after the resolve for the same reason
// `crate::sky`'s dome gate is: both backdrops must agree WITHIN a frame, or a frame
// shows the painted sky with our stars still punched through it.
.add_systems(
Update,
apply_celestial_visibility.after(crate::wmo_sky::WmoSkyResolve),
);
}
}
/// Hide the sky pass's own elements while a WMO skybox owns the sky ([`crate::wmo_sky`]).
///
/// `CSky::Render` gates its six draws on **one shared boolean** — `0x6d49cd` sets it, a filled skybox
/// slot clears it (`0x6d49fb`/`0x6d4a2e`, once a slot's weight exceeds `[0x808aac]` = 0.99), and
/// `0x6d4a3b test edi,edi; je` then skips **stars, sun disc, white moon, moon02, gradient band and
/// cloud dome together**. There is no per-element gating, which is why this is one system and not
/// five. Live-confirmed: standing in King's Square the reference's whole `[0.975, 0.98]` slice is
/// three `count=12` draws — the skybox cube — and nothing else.
///
/// **The GLARES are deliberately exempt.** They are not in this pass: the reference renders them
/// last, on their own path (`0x483740 → 0x6d48c0 → 0x7e57e0`), which the boolean never reaches — so
/// a sun flare still blooms over the painted sky. The gradient band is [`crate::sky`]'s dome and the
/// cloud dome is [`crate::clouds`]'s; each keeps its own authority (decision 0025) reading this same
/// resource.
#[allow(clippy::type_complexity)]
fn apply_celestial_visibility(
wmo_skybox: Res<crate::wmo_sky::CameraWmoSkybox>,
mut suns: Query<(&SunSprite, &mut Visibility), Without<MoonSprite>>,
mut moons: Query<(&MoonSprite, &mut Visibility), Without<SunSprite>>,
mut stars: Query<&mut Visibility, (With<StarDome>, Without<SunSprite>, Without<MoonSprite>)>,
) {
let owned = wmo_skybox.0.is_some();
// `Inherited`, not `Visible`: these sprites hang off the celestial rig and must keep deferring to
// it when the skybox stands down, exactly as the cloud dome's gate does.
let want = |in_sky_pass: bool| {
if in_sky_pass && owned {
Visibility::Hidden
} else {
Visibility::Inherited
}
};
let set = |vis: &mut Visibility, target: Visibility| {
if *vis != target {
*vis = target;
}
};
for (sprite, mut vis) in &mut suns {
set(&mut vis, want(matches!(sprite.part, SunPart::Disc)));
}
for (sprite, mut vis) in &mut moons {
set(
&mut vis,
want(matches!(sprite.part, MoonPart::Disc | MoonPart::Moon02)),
);
}
for mut vis in &mut stars {
set(&mut vis, want(true));
}
}
+211 -27
View File
@@ -25,6 +25,7 @@ use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use crate::assets::RenderConfig;
use crate::char_select::{ClientState, Roster};
use crate::clutter::{scatter_tile_clutter, ClutterConfig, GroundClutter};
use crate::collision::{GroundDecalSurface, PickOccluder};
use crate::interior::WmoResidency;
@@ -229,6 +230,60 @@ pub(crate) fn fold_interior_probe(
crate::lighting::prop_probe_coeffs(ambient, &lobes)
}
/// **Where the world streams from**, in WoW coordinates — one rule, shared by the detailed
/// streamer and the distant WDL ring (which used to carry its own copy under a comment promising
/// they agreed).
///
/// The order is a ladder of decreasing certainty:
///
/// 1. **The avatar**, once it is in the world and we are behind it — the answer for all of play.
/// 2. **The character you picked**, from its own roster row, while the world is loading and the
/// server has not yet sent the destination snap. This window is short (~265 ms measured on a
/// local server) but it is precisely the window in which the entry's IO budget is scarcest, and
/// without this leg the streamer spends it on [`SPAWN_XY`] — Northshire — for a character who
/// may be in Stratholme (decision 0777).
/// 3. **The camera**, which is the focus while free-flying (`detached`) and the only focus a
/// server-less capture run has.
/// 4. [`SPAWN_XY`], if there is not even a camera.
///
/// `entry` is [`Roster::pending_entry`] — passed as plain data rather than the roster itself, so
/// the ladder is a pure function of the four things that decide it, and testable as one.
pub(crate) fn view_focus(
player: &Player,
camera: Option<Vec3>,
entry: Option<(u32, [f32; 3])>,
) -> [f32; 3] {
if player.active && !player.detached {
return bevy_to_wow(player.pos);
}
if !player.active {
if let Some((_, pos)) = entry {
return pos;
}
}
match camera {
Some(t) => bevy_to_wow(t),
None => [SPAWN_XY.0, SPAWN_XY.1, 0.0],
}
}
/// **Which map** the streamers should be loading — [`CurrentMap`] once the server has told us,
/// and the picked character's own map for the entry window before that (the [`view_focus`] ladder's
/// second rung, and it must agree with it: a position from one map against the tile grid of
/// another names tiles that exist but are somewhere else entirely).
pub(crate) fn focus_map(
current: Option<u32>,
player: &Player,
entry: Option<(u32, [f32; 3])>,
) -> u32 {
if !player.active {
if let Some((map, _)) = entry {
return map;
}
}
current.unwrap_or(0)
}
/// A placement's model asset handle — an M2 doodad or a WMO building. Keeps the asset resident.
enum ModelHandle {
M2(Handle<M2Model>),
@@ -261,8 +316,17 @@ impl Plugin for TerrainPlugin {
sync_interior_volumes,
)
.chain()
.in_set(WorldStage::Stream),
.in_set(WorldStage::Stream)
// **No world until a character is in one** (decision 0777). The whole chain,
// not just the request side: with nothing streamed there is nothing to spawn,
// no collider to finish and no WMO volume to mirror, and gating the head alone
// would leave three systems walking empty queries to prove it every frame.
.run_if(crate::schedule::world_is_live),
)
// Leaving the world releases it. Without this, a `/logout` back to character select
// would leave the whole streamed world resident and unowned — the gate above stops it
// being *maintained*, which is precisely what makes an abandoned world immortal.
.add_systems(OnExit(ClientState::InWorld), release_world)
.add_systems(
Update,
// After the interior claim so the leaf override reads THIS frame's claim —
@@ -304,18 +368,29 @@ fn stream_terrain(
camera: Query<&Transform, With<WorldCamera>>,
shared_light: Option<Res<SharedLightBuffer>>,
cfg: Option<Res<RenderConfig>>,
current_map: Option<Res<CurrentMap>>,
map_catalog: Option<Res<MapCatalogRes>>,
// "Where are we" — the server's map, the catalog that names its directory, and the roster row
// of a pick still in flight (the entry window's focus). Bundled for the same param-limit
// reason as `asset_stores`.
location: (
Option<Res<CurrentMap>>,
Option<Res<MapCatalogRes>>,
Option<Res<Roster>>,
),
mut load_progress: Option<ResMut<WorldLoadProgress>>,
) {
let (mut materials, mut meshes, wdts, time) = asset_stores;
let (current_map, map_catalog, roster) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
// there's nothing to stream against, so idle.
let (Some(shared_light), Some(map_catalog)) = (shared_light, map_catalog) else {
return;
};
let placements = placements.into_inner();
let map_id = current_map.map(|m| m.0).unwrap_or(0);
let map_id = focus_map(
current_map.map(|m| m.0),
&player,
roster.as_deref().and_then(Roster::pending_entry),
);
let Some(dir) = map_catalog.0.directory(map_id).map(str::to_string) else {
return;
};
@@ -324,20 +399,15 @@ fn stream_terrain(
// handles + placements) so the new map streams in fresh, and request the new map's WDT (the
// tile-existence index every ADT request below consults).
if state.map_dir.as_deref() != Some(dir.as_str()) {
for ((_tx, _ty), t) in state.tiles.drain() {
despawn_tile_owned(&mut commands, &t);
for uid in t.placements {
release_placement(&mut commands, placements, uid);
}
}
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();
// Say which map's tiles we are about to spend the IO budget on, and how many we are
// throwing away to do it. Silent before, which is exactly why nobody noticed that every
// world entry began by streaming Northshire regardless of where the character was.
info!(
"terrain: streaming map {dir} (was {:?}, {} tiles released)",
state.map_dir,
state.tiles.len()
);
drop_streamed_world(&mut commands, &mut state, placements);
state.map_dir = Some(dir.clone());
state.wdt = Some(asset_server.load(format!("mpq://World/Maps/{dir}/{dir}.wdt")));
state.wdt_ungated = false;
@@ -385,15 +455,12 @@ fn stream_terrain(
}
}
// Stream around the *view focus*: the avatar in third-person, but the free-flying camera itself
// while detached or before we've connected — so the world loads around wherever you look.
let center = if player.active && !player.detached {
bevy_to_wow(player.pos)
} else if let Ok(cam) = camera.single() {
bevy_to_wow(cam.translation)
} else {
[SPAWN_XY.0, SPAWN_XY.1, 0.0]
};
// Stream around the *view focus* — see [`view_focus`] for the ladder.
let center = view_focus(
&player,
camera.single().ok().map(|c| c.translation),
roster.as_deref().and_then(Roster::pending_entry),
);
let radius = cfg.as_ref().map(|c| c.tile_radius as i32).unwrap_or(2);
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
let (cx, cy) = world_to_tile(center[0], center[1]);
@@ -479,6 +546,9 @@ fn stream_terrain(
Mesh3d(adt.mesh.clone()),
MeshMaterial3d(material),
Transform::IDENTITY, // the merged mesh is already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO it draws only through a portal window
// (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
if let Some((verts, tris)) = collider_data {
// `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`).
@@ -693,6 +763,60 @@ fn register_wmo(placements: &mut Placements, asset_server: &AssetServer, w: &Wmo
/// Despawn a tile's exclusively-owned entities — the terrain mesh, water surfaces, and per-chunk
/// `ClutterChunk`s (whose built meshes are children, so the despawn cascades). Shared doodad/WMO
/// placements are NOT touched here; they're refcount-released separately.
/// Drop every streamed tile and the placements they reference — the world, released.
///
/// One body, two triggers, because they are the same event seen from different sides: a cross-map
/// teleport ends the world you were in, and so does leaving for character select. 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), so it
/// is cleared *here*, sharing the exact trigger of the teardown it belongs to, rather than hanging
/// off `world_map::MapChange`.
fn drop_streamed_world(
commands: &mut Commands,
state: &mut TerrainStreamer,
placements: &mut Placements,
) {
for ((_tx, _ty), t) in state.tiles.drain() {
despawn_tile_owned(commands, &t);
for uid in t.placements {
release_placement(commands, placements, uid);
}
}
if std::mem::take(&mut state.global_wmo) {
release_placement(commands, placements, GLOBAL_WMO_UID);
}
placements.materials.clear();
}
/// Leaving `InWorld` (a `/logout` back to character select): release the world.
///
/// `map_dir`/`wdt` are cleared too, so the next entry reads as a fresh map rather than a resumed
/// one — otherwise a re-entry on the SAME map would take the "nothing changed" path and stream
/// against a `TerrainStreamer` whose tiles have all been despawned.
fn release_world(
mut commands: Commands,
mut state: ResMut<TerrainStreamer>,
mut placements: ResMut<Placements>,
mut progress: Option<ResMut<WorldLoadProgress>>,
) {
if state.tiles.is_empty() && state.map_dir.is_none() {
return;
}
info!(
"terrain: leaving the world — releasing {} tiles",
state.tiles.len()
);
drop_streamed_world(&mut commands, &mut state, &mut placements);
state.map_dir = None;
state.wdt = None;
state.wdt_ungated = false;
// The residency the loading screen reads is about a world that no longer exists; leaving it
// standing would let the next entry clear its cover against the previous world's counts.
if let Some(p) = progress.as_mut() {
**p = WorldLoadProgress::default();
}
}
fn despawn_tile_owned(commands: &mut Commands, t: &TileState) {
if let Some(e) = t.entity {
commands.entity(e).try_despawn();
@@ -747,3 +871,63 @@ fn terrain_base_material() -> StandardMaterial {
..default()
}
}
#[cfg(test)]
mod focus_tests {
use super::*;
/// Stratholme's coordinates, and a camera parked where `player::setup` leaves it at boot.
const PICK: (u32, [f32; 3]) = (329, [3398.9, -3381.8, 142.7]);
fn cam_at_northshire() -> Option<Vec3> {
Some(wow_to_bevy([SPAWN_XY.0, SPAWN_XY.1, 100.0]))
}
#[test]
fn the_pick_outranks_the_camera_until_the_avatar_exists() {
// The regression this guards is invisible and expensive: with the camera winning, world
// entry spends its scarcest IO on Northshire tiles for a character in Stratholme, then
// throws them away when the snap lands (decision 0777).
let idle = Player::default();
assert_eq!(
focus_map(Some(0), &idle, Some(PICK)),
329,
"the map must be the picked character's, not the boot default"
);
assert_eq!(view_focus(&idle, cam_at_northshire(), Some(PICK)), PICK.1);
}
#[test]
fn the_avatar_outranks_the_pick_once_it_is_in_the_world() {
// `pending_pick` deliberately survives world entry (0065's seamless reconnect re-answers
// with it), so a stale entry row must never pull the focus off a walking avatar.
let mut walking = Player::default();
walking.active = true;
walking.pos = wow_to_bevy([100.0, 200.0, 30.0]);
assert_eq!(focus_map(Some(1), &walking, Some(PICK)), 1);
let f = view_focus(&walking, cam_at_northshire(), Some(PICK));
assert!(
(f[0] - 100.0).abs() < 0.01 && (f[1] - 200.0).abs() < 0.01,
"{f:?}"
);
}
#[test]
fn free_flight_follows_the_camera_and_a_server_less_run_still_has_one() {
let mut flying = Player::default();
flying.active = true;
flying.detached = true;
flying.pos = wow_to_bevy([100.0, 200.0, 30.0]);
let f = view_focus(&flying, cam_at_northshire(), None);
assert!((f[0] - SPAWN_XY.0).abs() < 0.01, "{f:?}");
// A capture run: no roster, no avatar — the camera is the only answer, and the last-ditch
// fallback must still be the anchor rather than the origin.
assert!(
(view_focus(&Player::default(), cam_at_northshire(), None)[0] - SPAWN_XY.0).abs()
< 0.01
);
assert_eq!(
view_focus(&Player::default(), None, None),
[SPAWN_XY.0, SPAWN_XY.1, 0.0]
);
}
}
@@ -57,9 +57,11 @@ pub(crate) fn spawn_model_entities(
// and interior props (their lighting lanes don't read it).
shade: ShadeSel,
// `Some(slot)` for an INTERIOR M2 prop: its folded SH probe's table slot, carried per-instance
// in `MeshTag` (read by `wow_model.wgsl`'s interior-prop lane); such props never distance-fade.
// `None` everywhere else (exterior props fade; WMO groups use their own per-submesh interior
// flag + batch class).
// in `MeshTag` on EVERY batch of the model, billboard cards included (read by
// `wow_model.wgsl`'s interior-prop lane). Its ordinary batches are steady indoors; a card still
// distance-fades with its doodad, and the fade composes with the slot rather than clobbering it
// (decision 0778). `None` everywhere else (exterior props fade; WMO groups use their own
// per-submesh interior flag + batch class).
interior_slot: Option<u16>,
radius: f32,
local_center: Vec3,
@@ -140,10 +142,21 @@ pub(crate) fn spawn_model_entities(
// never seen — and an author who wanted one seen set `0x04` themselves: Elwynn's LampPost
// carries a two-sided −X card AND a single-sided +X glow card, one model, both rules.
let two_sided = sub.two_sided;
// A lit interior M2 prop submesh (not a WMO group, not an emissive billboard glow card): it carries
// its SH-probe slot in `MeshTag` and is steady (no distance fade), so the fade twin/`DoodadFade`
// are skipped — otherwise the fade system would overwrite the slot the shader reads from the tag.
let lit_interior_prop = !is_wmo && sub.billboard.is_none() && interior_slot.is_some();
// A lit interior M2 prop submesh (not a WMO group): it carries its SH-probe slot in
// `MeshTag`, so the shader evaluates the room's probe instead of the sky base. **Billboard
// batches are included** — a chain or glow card is a batch of the same model, and the
// reference shades every batch of an object through one light node (decision 0778). They
// were excluded here on a since-stale worry that the distance fade would overwrite the
// slot: true before the 0355 re-lane, when the slot lived in the alpha bits, and false
// after it — the fade writes through `mesh_tag::with_alpha`, which composes with the slot
// (`debug_panel::visibility`). Excluding them left an interior card on an interior-mode
// material with NO slot in its tag, which the shader decodes as slot **0** — whichever
// probe won the streaming race, the same wrong-probe read the 0355 note below describes.
let interior_probe = !is_wmo && interior_slot.is_some();
// …and the narrower half: only a NON-card interior prop is steady indoors. A card still
// distance-fades with its doodad (below — the halo must cull when the lamp does), so it
// keeps its fade twin and its `DoodadFade`.
let steady_interior_prop = interior_probe && sub.billboard.is_none();
let cutout = model_material(
mat_cache,
materials,
@@ -171,7 +184,7 @@ pub(crate) fn spawn_model_entities(
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
// ARMORREFLECT sheen) also reuses its steady self: its blend equation reads no alpha, so it
// cannot feather — the reference's instanceAlpha fade leaves it at full strength (0528).
let blend = if lit_interior_prop
let blend = if steady_interior_prop
|| matches!(
sub.blend,
ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x
@@ -225,7 +238,7 @@ pub(crate) fn spawn_model_entities(
// bits-0..=15 write through that re-lane, so every static interior prop read probe slot 0,
// taking whichever probe won the streaming race — the director's inn-doodad regression.
let mesh_tag = match interior_slot {
Some(slot) if lit_interior_prop => MeshTag(crate::mesh_tag::probe_bits(slot)),
Some(slot) if interior_probe => MeshTag(crate::mesh_tag::probe_bits(slot)),
_ => MeshTag(alpha_bits(1.0)),
};
// A billboard batch (glow card / chain) faces the camera each frame, so its transform is owned
@@ -321,7 +334,7 @@ pub(crate) fn spawn_model_entities(
}
// Distance fade for everything except a lit interior prop (whose `MeshTag` carries colour, and
// which is steady indoors). Adding `DoodadFade` is what makes the fade system drive the tag.
if !lit_interior_prop {
if !steady_interior_prop {
commands.entity(entity).insert(DoodadFade {
radius,
local_center: fade_center,
@@ -123,6 +123,16 @@ pub(super) fn spawn_loaded_placements(
&mut tint_reg,
None, // world-static placement: cards bake their world pivot
);
// ADT doodads are exterior scene: from inside a WMO they draw only through a
// portal window (`0x683700`, fed solely by the per-window walk `0x682fa0` — see
// `crate::exterior_cull`). Tagged per submesh because a placement has no root
// entity to carry the bound; the deviation from the reference's whole-object test
// is recorded in `exterior_cull`'s module doc.
for e in &ents {
commands
.entity(*e)
.insert(crate::exterior_cull::ExteriorScene);
}
// Doodad collider (avian): a static trimesh hull baked at this placement's transform,
// built off-thread, as its own entity so it despawns with the placement. `None` ⇒
// the model has no collision hull. A world-static doodad hull also clamps the
+87 -46
View File
@@ -103,6 +103,13 @@ pub(super) fn drive_script(
let printing = ui_cost_enabled();
let cost_on = printing || crate::hover_log::enabled();
let solves_before = cost_on.then(|| script.layout_solves());
// The measure counters are PER FRAME: `measure_fontstrings` adds to them, and both publish
// sites below carry them forward so the two measure passes sum into one frame's row. Nothing
// else zeroes them, so without this the recorder's "re-shaped strings" column is a lifetime
// total wearing a per-frame label — it read a flat 219/frame on a glue screen that measures
// nothing after startup, and cost one wrong theory before the CSV contradicted it.
ui_cost.measured = 0;
ui_cost.measured_texts.clear();
let mut t_mark = cost_on.then(std::time::Instant::now);
// Marks phase boundaries under the meter: returns μs since the previous mark and re-arms.
// With the meter off it does nothing (a run carries six no-op calls, not six clock reads).
@@ -120,10 +127,37 @@ pub(super) fn drive_script(
script.tick(time.delta_secs());
}
let us_tick = lap();
// ── The FontString measure round-trip, BEFORE the resolve ────────────────────────────────
// `fontstrings_needing_measure` reads only `region_data` — each FontString's text, font, and
// its explicit/wrap-pinned size — and never a resolved rect; nothing in `resolve` writes a
// region's size either. So the answers do not need a layout pass to exist, and running them
// first means the frame's ONE resolve already sees them.
//
// Measuring after the resolve (the old order) cost a second FULL solve on every frame whose
// text changed, because answering a measure moves the anchor solve's read set. On the shipped
// UI that solve walks 2,164 frames and sweeps 6,297 regions per round — for a hover whose
// change touches ten FontStrings inside one frame. Measured on the default UI with a tooltip
// content change per frame (`resolve_bench`): 3.22 ms/frame at 2.00 solves → 2.37 ms at 1.00.
// The live shape this comes from: a hover frame that solved cost ~10 ms against ~0.2 ms for
// one that did not (`WOW_HOVER_LOG`, director's run).
let mut measured_any = false;
if let Some(atlas) = font_atlas.as_deref_mut() {
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost);
}
{
let _span = bevy::log::info_span!("ui_script: resolve").entered();
script.resolve();
}
// The backstop: only when this frame actually measured something can a fresh request exist
// that the pass above could not have seen. Empty on every frame in the bench — and skipping it
// otherwise keeps the quiet frame at ONE sweep of the 6,297-region map, not two.
if measured_any {
if let Some(atlas) = font_atlas.as_deref_mut() {
if measure_fontstrings(&mut script, atlas, s, &mut ui_cost) {
script.resolve();
}
}
}
let us_resolve = lap();
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
@@ -157,52 +191,6 @@ pub(super) fn drive_script(
}
}
}
// The measure round-trip (the layout↔font-engine seam): height-less FontStrings ask the host
// for their wrapped text size; answer from the atlas and resolve again so anchors hanging off
// a text's bottom (the gossip option rows → the greeting) land this same frame. The cache keys
// keep the request list empty on quiet frames, so the second resolve is rare.
if let Some(atlas) = font_atlas.as_deref_mut() {
let requests = script.fontstrings_needing_measure();
// The recorder's churn column: WHICH strings a frame had to re-shape. A steady hover that
// keeps asking is the whole question (`hover_log`), and the answer is a string, not a
// count — so the first few come along by name.
if crate::hover_log::enabled() {
ui_cost.measured = requests.len();
ui_cost.measured_texts = requests
.iter()
.take(3)
.map(|r| r.text.chars().take(40).collect::<String>())
.collect();
}
if !requests.is_empty() {
let measures: Vec<(u32, f32, f32, u64)> = requests
.iter()
.map(|r| {
let (w, h) = crate::ui_text::measure_text(
atlas,
&r.text,
r.wrap_width.map(|w| w * s),
crate::ui_text::FontSpec {
path: r.font.as_deref(),
// The render pass's exact drawn px (two regimes × the virtual
// scale) — measure == render, and Lua's GetStringWidth/Height echo
// the DRAWN size (0x772890); results divide back to UI units below.
height: crate::ui_text::drawn_px(r.height, r.text_height, s),
// The TRUE outline: THICK biases the client's step law (+1px per
// glyph — GlyphStepBase 0x5ca2b0, THICK-only per outline-bake-tint.md)
// and any outline adds the +2r line pitch, so measure must see it.
outline: r.outline,
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None, // alpha never changes metrics
},
);
(r.id, w / s, h / s, r.key)
})
.collect();
script.set_measured_text(&measures);
script.resolve();
}
}
// The message-line half of the round-trip: chat ring lines ask for their wrapped ROW COUNT at
// the frame's resolved width, so the emit pass can stack real content heights (a long line
// pushes older lines up instead of overlapping them). Same-frame like the FontString half, but
@@ -621,6 +609,59 @@ pub(super) fn drive_script(
}
}
/// One pass of the FontString measure round-trip: hand every unmeasured FontString to the font
/// engine and push the answers back. Returns whether anything was measured — the caller's gate for
/// its backstop pass (see the call site for why the order matters).
fn measure_fontstrings(
script: &mut UiScript,
atlas: &mut UiFontAtlas,
s: f32,
ui_cost: &mut crate::hover_log::UiFrameCost,
) -> bool {
let requests = script.fontstrings_needing_measure();
// The recorder's churn column: WHICH strings a frame had to re-shape. A steady hover that
// keeps asking is the whole question (`hover_log`), and the answer is a string, not a count —
// so the first few come along by name.
if crate::hover_log::enabled() {
ui_cost.measured += requests.len();
ui_cost.measured_texts.extend(
requests
.iter()
.take(3)
.map(|r| r.text.chars().take(40).collect::<String>()),
);
}
if requests.is_empty() {
return false;
}
let measures: Vec<(u32, f32, f32, u64)> = requests
.iter()
.map(|r| {
let (w, h) = crate::ui_text::measure_text(
atlas,
&r.text,
r.wrap_width.map(|w| w * s),
crate::ui_text::FontSpec {
path: r.font.as_deref(),
// The render pass's exact drawn px (two regimes × the virtual scale) —
// measure == render, and Lua's GetStringWidth/Height echo the DRAWN size
// (0x772890); results divide back to UI units below.
height: crate::ui_text::drawn_px(r.height, r.text_height, s),
// The TRUE outline: THICK biases the client's step law (+1px per glyph —
// GlyphStepBase 0x5ca2b0, THICK-only per outline-bake-tint.md) and any outline
// adds the +2r line pitch, so measure must see it.
outline: r.outline,
paint_halo: true, // measure never paints; irrelevant here
alpha_gradient: None, // alpha never changes metrics
},
);
(r.id, w / s, h / s, r.key)
})
.collect();
script.set_measured_text(&measures);
true
}
/// The held-cursor icon quad (CAPTURE-ONLY — see the module doc): a 32×32 icon TOP-LEFT anchored
/// at the mouse (`pos`, y-down logical px — the same space as the extracted quad rects and the
/// hardware cursor's own coordinate origin), `[pos, pos + 32]` — matching the hardware cursor's
+2
View File
@@ -738,6 +738,8 @@ mod chat_tests;
#[cfg(test)]
mod bag_tests;
#[cfg(test)]
mod resolve_bench;
#[cfg(test)]
mod tooltip_anchor_tests;
@@ -0,0 +1,157 @@
//! What a UI change costs on the **full shipped UI** — the headless twin of the live hover
//! recorder (`hover_log`), and the loop that made the tooltip-hover cost reproducible without a
//! play session.
//!
//! The live runs said a hover frame that ran the layout fixpoint cost ~10 ms against ~0.2 ms for
//! one that did not, but neither the synthetic tooltip probes nor the shipped `BagFrame.xml` hover
//! loop reproduced it: with content that does not MOVE, the change gate absorbs everything. The
//! missing ingredient is that a real hover sweep shows a *different item every frame* — line widths
//! that actually change. That is what [`tooltip_change_costs_a_whole_ui_solve`] drives, and with it
//! the cost reproduces headlessly and can be measured against a fix.
//!
//! Run with `cargo test --release -p benilla -- --ignored --nocapture resolve_bench`;
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape (rounds, graph size, frame-vs-region split).
use std::time::Instant;
use benilla_ui::script::UiScript;
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
/// The deterministic stand-in for the app's font engine: every string has one size, so a measure
/// answer is stable across frames and only real text changes move the layout.
fn answer_measures(s: &mut UiScript) -> usize {
let reqs = s.fontstrings_needing_measure();
let n = reqs.len();
if n > 0 {
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
#[allow(clippy::cast_precision_loss)]
let w = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(ww) if w > ww => (r.id, ww, 12.0 * (w / ww).ceil(), r.key),
_ => (r.id, w, 12.0, r.key),
}
})
.collect();
s.set_measured_text(&answers);
}
n
}
/// The full shipped UI, loaded and settled — the graph a real hover actually pays for.
fn settled_default_ui() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
super::load_default_ui(&s);
s.set_screen_size(1600.0, 900.0);
for _ in 0..12 {
s.resolve();
if answer_measures(&mut s) == 0 {
break;
}
}
s
}
/// One frame in the app's own order (`extract::drive_script`): measure FIRST, then resolve.
fn app_frame(s: &mut UiScript) {
answer_measures(s);
s.resolve();
}
/// A tooltip whose lines change width every call — the hover sweep across a bag grid, where each
/// slot holds a different item.
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
s.run(&format!(
r#"
local a = CreateFrame("Button", "{owner}"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
{func}_n = 0
function {func}()
{func}_n = {func}_n + 1
GameTooltip:SetOwner({owner}, "ANCHOR_RIGHT")
local pad = string.rep("x", math.mod({func}_n, 17) + 1)
GameTooltip:AddLine("Item " .. pad, 1, 1, 1)
GameTooltip:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1)
GameTooltip:AddLine("Armor " .. pad)
GameTooltip:AddLine("Use: restores " .. pad .. " health over 21 sec.", 0, 1, 0, 1)
GameTooltip:Show()
end
"#
))
.unwrap();
}
#[test]
#[ignore = "bench, run explicitly"]
fn tooltip_change_costs_a_whole_ui_solve() {
let mut s = settled_default_ui();
let (r0, s0) = (s.layout_rounds(), s.layout_solves());
let t = Instant::now();
for _ in 0..20 {
s.resolve();
}
println!(
"QUIET {:.3} ms/resolve solves={} rounds={}",
ms(t) / 20.0,
s.layout_solves() - s0,
s.layout_rounds() - r0
);
install_changing_tooltip(&s, "BenchOwner", "change");
let (r1, s1) = (s.layout_rounds(), s.layout_solves());
let n = 50u32;
let t = Instant::now();
for _ in 0..n {
s.run("change()").unwrap();
app_frame(&mut s);
}
println!(
"CHANGED {:.3} ms/frame solves/frame={:.2} rounds/frame={:.2}",
ms(t) / f64::from(n),
(s.layout_solves() - s1) as f64 / f64::from(n),
(s.layout_rounds() - r1) as f64 / f64::from(n),
);
}
/// The gate the measure-first order exists to hold: a tooltip content change costs the frame
/// **one** layout solve, not two.
///
/// The old order resolved, then measured, then had to resolve AGAIN because the answers moved the
/// anchor solve's read set — and on the shipped UI each of those walks 2,164 frames and sweeps
/// 6,297 regions per round, for a change that touches ten FontStrings inside one frame. Nothing
/// about the second solve is tooltip-sized, and nothing about it is cheap.
#[test]
fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
let mut s = settled_default_ui();
install_changing_tooltip(&s, "GateOwner", "gate_change");
// Settle the newly-created owner + the tooltip's first content.
for _ in 0..4 {
s.run("gate_change()").unwrap();
app_frame(&mut s);
}
let before = s.layout_solves();
for _ in 0..10 {
s.run("gate_change()").unwrap();
app_frame(&mut s);
}
let solves = s.layout_solves() - before;
assert_eq!(
solves, 10,
"10 content changes must cost 10 solves — one each. Two per change means the measure \
round-trip is running AFTER the resolve again (extract::drive_script's order)."
);
// …and the measure answers must have landed in that one solve: a frame that resolved before
// measuring leaves the fresh text unmeasured until the next frame, which is the visible half
// of the same bug (a tooltip plate one frame behind its content).
assert_eq!(
answer_measures(&mut s),
0,
"after a settled frame nothing may still be waiting to be measured"
);
}
+35 -14
View File
@@ -19,7 +19,6 @@ use crate::assets::{AssetSet, RenderConfig, WorldAssets};
use crate::player::{Player, WorldCamera};
use crate::terrain::{WdlExt, WdlMaterial};
use crate::world_map::{CurrentMap, MapCatalogRes};
use crate::SPAWN_XY;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_formats::WdlFile;
@@ -43,7 +42,13 @@ impl Plugin for WdlPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<WdlMaterial>::default())
.add_systems(Startup, setup_wdl.after(AssetSet::Open))
.add_systems(Update, stream_wdl);
// The distant ring is world, and follows the world's lifecycle (decision 0777): it
// spawns only while a character is in one, and its meshes go when they leave.
.add_systems(Update, stream_wdl.run_if(crate::schedule::world_is_live))
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
release_wdl_ring,
);
}
}
@@ -129,42 +134,48 @@ fn stream_wdl(
camera: Query<&Transform, With<WorldCamera>>,
current_map: Res<CurrentMap>,
map_catalog: Res<MapCatalogRes>,
roster: Option<Res<crate::char_select::Roster>>,
mut meshes: ResMut<Assets<Mesh>>,
) {
let Some(mut streamer) = streamer else {
return;
};
// The map the *focus* is on — `CurrentMap` once the snap has landed, the picked character's
// own map during world entry, so the ring is never built for the map we are leaving.
let entry = roster
.as_deref()
.and_then(crate::char_select::Roster::pending_entry);
let map_id = crate::terrain_stream::focus_map(Some(current_map.0), &player, entry);
// Cross-map teleport: reload the new map's `.wdl` and drop the old ring.
if current_map.0 != streamer.map_id {
if let Some(dir) = map_catalog.0.directory(current_map.0) {
if map_id != streamer.map_id {
if let Some(dir) = map_catalog.0.directory(map_id) {
match WdlFile::load(&mut assets.chain.lock_recover(), dir) {
Ok(wdl) => {
for (_, e) in streamer.loaded.drain() {
commands.entity(e).despawn();
}
streamer.wdl = wdl;
streamer.map_id = current_map.0;
streamer.map_id = map_id;
}
// No `.wdl` for this map (instances often lack one) → just clear the ring.
Err(_) => {
for (_, e) in streamer.loaded.drain() {
commands.entity(e).despawn();
}
streamer.map_id = current_map.0;
streamer.map_id = map_id;
}
}
}
}
// Same view-focus rule as terrain_stream: avatar in third-person, free-flying camera otherwise.
let center = if player.active && !player.detached {
bevy_to_wow(player.pos)
} else if let Ok(cam) = camera.single() {
bevy_to_wow(cam.translation)
} else {
[SPAWN_XY.0, SPAWN_XY.1, 0.0]
};
// The same view focus the detailed streamer uses — literally the same function now, rather
// than a copy under a comment claiming they agree (decision 0777).
let center = crate::terrain_stream::view_focus(
&player,
camera.single().ok().map(|c| c.translation),
entry,
);
// The FULL window, the camera's own tile INCLUDED (`tiles_in_ring`'s doc is the why — at a
// lowered view distance the own tile *is* the near horizon, and dropping it leaves a gap the sky
// pours through). What bounds the band on the near side is the shader's near plane, never the
@@ -222,6 +233,16 @@ fn stream_wdl(
}
}
/// Leaving the world drops the distant ring (decision 0777). The parsed `.wdl` itself stays — it is
/// one small file, the resource `stream_wdl` needs to exist at all, and `height_under` is read by
/// the sun's flare-occlusion march; what costs per frame is the spawned mesh set, and that goes.
fn release_wdl_ring(mut commands: Commands, streamer: Option<ResMut<WdlStreamer>>) {
let Some(mut streamer) = streamer else { return };
for (_, e) in streamer.loaded.drain() {
commands.entity(e).despawn();
}
}
/// The backdrop law (`wdl.wgsl`'s header, decision 0684) is a property of the **shader**, so it is
/// checked there — the same shape as `sky_order.rs`'s depth-law test, and for the same reason: the
/// failure it guards is invisible except at a ridge crest on a fogged horizon, and the obvious "tidy-up"
@@ -371,6 +371,11 @@ fn inn_corridor_light_probe() {
/// `WOW_LIGHT_AT="map,x,y,z"` in raw WoW world coords (the tele table's numbers), so a
/// director-reported "characters look wrong HERE" becomes one command. Born from the 2026-07-18
/// report: chars/creatures mis-lit across the city WMOs (Booty Bay / Stormwind / Orgrimmar).
///
/// The point is the **down-ray lane's** anchor — a unit's position. A GameObject anchors at its
/// world bounding-box CENTRE instead (decision 0776), so to read a GameObject's lane, probe at
/// `z + centre` (`benilla-extract m2coll <model>` prints the box; the two Stratholme portcullises
/// that found 0776 read `exterior` at their spawn z and `BAKE g02` at their centres).
#[test]
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
fn world_point_light_probe() {
+117 -7
View File
@@ -371,11 +371,38 @@ pub(crate) fn indoors_at<'a>(
feet_world: Vec3,
) -> bool {
matches!(
indoor_verdict_at(wmos, instances, streamer, adt_tiles, feet_world),
indoor_verdict_at(
wmos,
instances,
streamer,
adt_tiles,
feet_world,
LightAttach::DownRay,
),
IndoorVerdict::DayNight | IndoorVerdict::Baked { .. }
)
}
/// Which ATTACH a light node uses to find its WMO group — the reference's `[node+0x90]` bit 13
/// (`0x2000`), written once at node creation from the descriptor TYPEMASK (`0x613e10`/`0x670db0`)
/// and read by the mode dispatch `0x6a86d0` (`6a8714 test ah,0x20`; `6a871c jmp 0x6a8c10` SET /
/// `6a8721 jmp 0x6a8a20` CLEAR). **There is no subclass or vtable test anywhere in the dispatch** —
/// the mode bit is the whole fork, so the two lanes are a property of the OBJECT KIND, not of the
/// shading law (which stays one law for every entity M2; see [`crate::interior`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum LightAttach {
/// Units, players, and ADT/WMO doodads — the down-ray attach `0x6a8a20`, cast from the node
/// POSITION (`[node+0xa8]`, the env-query position `0x6717d0` syncs at `671a48`).
DownRay,
/// GameObjects — the CONTAINMENT attach `0x6a8c10` → `0x6a8ed0`, whose segment starts at
/// `[node+0x5c]`: the node's world bounding-box **centre** (`0x6717d0` writes the world box to
/// `node+0x44` at `671973`, then `671978 call 0x621160` sums min+max and `× [0x7ffa24]=0.5`
/// lands the centre at `node+0x5c`). Its face query casts DOWN 1000 and, on a miss, **retries
/// UP 1000** (`6a908d..6a90c7`) — a GameObject may sit a hair under its own floor and still
/// belong to the room.
Containment,
}
/// A position's resolved interior LIGHT verdict — [`indoors_at`] plus, for the GameObject lane,
/// the footprint bake ([`crate::interior`] folds it into the object's SH probe).
pub(crate) enum IndoorVerdict {
@@ -414,14 +441,23 @@ pub(crate) enum IndoorVerdict {
/// `0x69e4c0` — wow-re `wmo-lit-selector.md`, trace-decisive on the abbey INNBENCH draws). The
/// MOLR gate is the FOOTPRINT hit's group — the group whose bake the sample took (the byte
/// plumbing between the two rays is OPEN; the abbey benches satisfy both readings).
///
/// `anchor_world` is **the attach's own anchor**, not "the position": the node position for
/// [`LightAttach::DownRay`], the model's world bounding-box CENTRE for
/// [`LightAttach::Containment`] (see the enum for the bytes). A GameObject's origin routinely sits
/// at or under its own floor — a Stratholme portcullis spawns 15 cm below the corridor slab — and
/// a ray from there leaves the building entirely; the reference never casts from there (0776).
/// `Containment` also takes the reference's UPWARD retry on the footprint leg (`6a908d`), so an
/// object resting a hair below its floor still bakes from it.
pub(crate) fn indoor_verdict_at<'a>(
wmos: &Assets<WmoModel>,
instances: impl IntoIterator<Item = &'a WmoPortalInstance>,
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
feet_world: Vec3,
anchor_world: Vec3,
attach: LightAttach,
) -> IndoorVerdict {
let probe_world = feet_world + Vec3::Y * POSITION_PROBE_LIFT;
let probe_world = anchor_world + Vec3::Y * POSITION_PROBE_LIFT;
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
let mut best: Option<(DownRayClaim, &WmoModel, &WmoPortalInstance, [f32; 3])> = None;
for inst in instances {
@@ -455,8 +491,17 @@ pub(crate) fn indoor_verdict_at<'a>(
if claim.outdoor {
return IndoorVerdict::OutdoorsOnWmo;
}
// Indoors — run the footprint sample on the winning placement's render mesh.
let Some((gi, mocv, mopy_daynight)) = footprint_sample(model, probe_local) else {
// Indoors — run the footprint sample on the winning placement's render mesh. The containment
// lane retries UPWARD on a miss (`6a908d..6a90c7`); the down-ray lane has no such leg here (its
// own retry is a second DOWNWARD cast from 1000 above, `6a8ab0`, which cannot reach a face
// above the anchor either). Bounded to the placement the claim already won: an unbounded
// upward claim would let a bridge deck 40 yd overhead adopt a unit standing in the open.
let sample = footprint_sample(model, probe_local).or_else(|| {
matches!(attach, LightAttach::Containment)
.then(|| footprint_sample_above(model, probe_local))
.flatten()
});
let Some((gi, mocv, mopy_daynight)) = sample else {
return IndoorVerdict::DayNight;
};
if mopy_daynight {
@@ -492,6 +537,27 @@ pub(crate) fn indoor_verdict_at<'a>(
pub(super) fn footprint_sample(
model: &WmoModel,
probe_local: [f32; 3],
) -> Option<(usize, [u8; 3], bool)> {
footprint_scan(model, probe_local, false)
}
/// The containment attach's **upward retry** (`6a908d`: the down leg missed, so the segment is
/// rebuilt from the anchor to `anchor.z + 1000` and re-cast): the nearest render-mesh face ABOVE
/// the probe, same group/MOCV/MOPY answer as [`footprint_sample`]. A GameObject whose origin — or
/// even whose box centre — settles below its own floor is still lit by that floor.
pub(super) fn footprint_sample_above(
model: &WmoModel,
probe_local: [f32; 3],
) -> Option<(usize, [u8; 3], bool)> {
footprint_scan(model, probe_local, true)
}
/// The shared body of the two footprint legs: `up` flips which side of the probe a face must lie
/// on and which of the candidates wins (nearest, in the cast's own direction).
fn footprint_scan(
model: &WmoModel,
probe_local: [f32; 3],
up: bool,
) -> Option<(usize, [u8; 3], bool)> {
let (px, py, pz) = (probe_local[0], probe_local[1], probe_local[2]);
let mut best: Option<(f32, usize, [f32; 3], bool)> = None;
@@ -504,7 +570,8 @@ pub(super) fn footprint_sample(
// entity scanned every interior group of the whole model — the Stormwind live-frame cost
// (decision 0364).
if let Some(Some((min, max))) = model.group_footprint_bounds.get(gi) {
if px < min[0] || px > max[0] || py < min[1] || py > max[1] || min[2] > pz {
let past_probe = if up { max[2] < pz } else { min[2] > pz };
if px < min[0] || px > max[0] || py < min[1] || py > max[1] || past_probe {
continue;
}
}
@@ -534,7 +601,11 @@ pub(super) fn footprint_sample(
return;
}
let z = wa * a[2] + wb * b[2] + wc * c[2];
if z > pz || best.is_some_and(|(bz, ..)| z < bz) {
let (past_probe, farther) = match up {
false => (z > pz, best.is_some_and(|(bz, ..)| z < bz)),
true => (z < pz, best.is_some_and(|(bz, ..)| z > bz)),
};
if past_probe || farther {
return;
}
let (Some(ca), Some(cb), Some(cc)) = (
@@ -742,6 +813,45 @@ mod tests {
assert_eq!(footprint_sample(&model, [-1.0, -1.0, -1.0]), None);
}
/// **The containment attach's upward retry** (`6a908d..6a90c7`): the down leg missed, so the
/// segment is re-cast toward `anchor.z + 1000` and the nearest face ABOVE answers — with the
/// same group / MOCV / MOPY it would have given from below. This is the leg a GameObject
/// resting a hair under its own floor needs; the down-ray lane never takes it (decision 0776).
#[test]
fn the_upward_retry_finds_the_floor_a_sunk_object_sits_under() {
let mut model = bare_model();
model.group_footprints = two_group_footprints();
model.group_footprint_bounds = footprint_tri_bounds(&model.group_footprints);
// Group 0's face is at z = 0, over the column (-1,-1). Sit 2 cm UNDER it, as a portcullis
// spawned below its own slab does.
let sunk = [-1.0, -1.0, -0.02];
assert_eq!(
footprint_sample(&model, sunk),
None,
"the downward leg has nothing below it — this is the whole defect"
);
assert_eq!(
footprint_sample_above(&model, sunk),
Some((0, [10, 20, 30], false)),
"the retry finds the slab it is sunk into, and reads the same bake"
);
// From above, the two legs swap roles — the retry must not become a second downward scan.
let over = [-1.0, -1.0, 2.0];
assert_eq!(
footprint_sample(&model, over),
Some((0, [10, 20, 30], false))
);
assert_eq!(
footprint_sample_above(&model, over),
None,
"nothing above the probe, so the retry declines rather than re-finding the floor"
);
// The upward leg honours the broad phase the same way: group 1's face (z = 5) sits far off
// in XY, so a column that misses it in XY misses it looking up too.
assert_eq!(footprint_sample_above(&model, [-1.0, -1.0, 1.0]), None);
}
/// The derived bounds cover only vertices a face actually references — an orphan vertex
/// (parsed but unindexed) must not inflate the cull box.
#[test]
+65 -4
View File
@@ -37,7 +37,8 @@ mod seed;
use fog::select_wmo_fog;
pub use fog::{CameraWmoFog, WmoFogTarget};
pub(crate) use interior::{
indoor_verdict_at, indoors_at, terrain_z_local, IndoorVerdict, INTERIOR_PROBE_HEIGHT,
indoor_verdict_at, indoors_at, terrain_z_local, IndoorVerdict, LightAttach,
INTERIOR_PROBE_HEIGHT,
};
use interior::{track_area_interior, track_current_interior, track_unit_interiors};
pub use interior::{CurrentAreaInterior, CurrentWmoInterior, PlayerWmoRoom, UnitWmoRoom};
@@ -100,9 +101,30 @@ const MAX_ITERS: u32 = 1 << 16;
/// An NDC axis-aligned rectangle `[min_x, min_y, max_x, max_y]` — the "screen frustum" carried through
/// the flood. The full screen is `[-1, -1, 1, 1]`.
type Rect = [f32; 4];
pub(crate) type Rect = [f32; 4];
const FULL_SCREEN: Rect = [-1.0, -1.0, 1.0, 1.0];
/// The **deferred exterior-window worklist** — the doorways through which the outdoor world is
/// allowed to draw this frame, as NDC rects ([`Rect`]).
///
/// This is the client's `[0xcbe324]` list (count `[0xcbe320]`, stride `0x14` =
/// `{x0, y0, x1, y1, portalMaxPlaneDist}` in 0..1 screen space), which the world-scene driver
/// `0x681070` copies into `0xc7cb7c` and then walks **once per window**, with the frustum narrowed to
/// that window's rect. `0x682fa0` is the only producer for every exterior bucket, so no window in
/// frame means no exterior content at all (`0x681199 jbe 0x681204` skips the whole walk on count 0).
///
/// [`Self::Unrestricted`] is the driver's **outside leg** (`0x6811ca`): with no containing WMO the
/// client runs one populate walk against the literal full-screen rect, i.e. the ordinary view frustum.
#[derive(Resource, Default, Clone, PartialEq, Debug)]
pub enum ExteriorWindows {
/// The camera is not inside a WMO interior — the whole frustum is the window.
#[default]
Unrestricted,
/// The camera is inside. Exterior content draws only where it survives one of these sub-frusta;
/// **empty means nothing exterior draws**, which is the sealed-room case and is correct.
Windows(Vec<Rect>),
}
/// Static tag on every portal-culled WMO piece — a group render submesh, a group's MLIQ surface, a
/// MODD prop — naming which placement instance it belongs to and which absolute group indices can
/// draw it, so the visibility authority can look up their per-frame PVS bits.
@@ -202,6 +224,7 @@ impl Plugin for WmoPortalPlugin {
.init_resource::<CurrentAreaInterior>()
.init_resource::<CameraWmoFog>()
.init_resource::<CameraInteriorClaim>()
.init_resource::<ExteriorWindows>()
.init_resource::<WmoCullProbe>()
.add_systems(
Update,
@@ -232,6 +255,7 @@ fn compute_wmo_pvs(
mut probe: ResMut<WmoCullProbe>,
mut camera_fog: ResMut<CameraWmoFog>,
mut camera_claim: ResMut<CameraInteriorClaim>,
mut camera_windows: ResMut<ExteriorWindows>,
) {
// The world Camera3d (the egui overlay is a Camera2d). None before it spawns ⇒ leave last frame's
// sets untouched (everything was visible, which is the safe default).
@@ -251,6 +275,8 @@ fn compute_wmo_pvs(
// group wins both.
let mut fog_target: Option<WmoFogTarget> = None;
let mut claim: Option<InteriorClaim> = None;
// `None` here = the driver's outside leg (no containing map object): one full-screen walk.
let mut windows: Option<Vec<Rect>> = None;
for (entity, mut inst) in &mut instances {
let Some(model) = wmos.get(&inst.handle) else {
continue;
@@ -315,6 +341,23 @@ fn compute_wmo_pvs(
.zip(&inst.visible)
.any(|(n, v)| *v && n.flags & 0x148 != 0),
});
// The deferred exterior-window list is this claiming placement's alone: the
// reference floods the CONTAINING map object (`[0xc7b748]`) and defers that
// flood's windows. A step whose destination is EXTERIOR-flagged
// (`0x148`, the same mask the weather gate uses) IS a window onto the
// outdoor world, and the rect it was entered on is the accumulated clip.
windows = Some(
tap.entered
.iter()
.filter(|(to, _)| {
model
.group_nav
.get(*to)
.is_some_and(|n| n.flags & 0x148 != 0)
})
.map(|(_, rect)| *rect)
.collect(),
);
}
}
}
@@ -326,6 +369,13 @@ fn compute_wmo_pvs(
if camera_claim.0 != claim {
camera_claim.0 = claim;
}
let want_windows = match windows {
Some(rects) => ExteriorWindows::Windows(rects),
None => ExteriorWindows::Unrestricted,
};
if *camera_windows != want_windows {
*camera_windows = want_windows;
}
if let Some(text) = dump_text {
match std::fs::write(PROBE_DUMP_PATH, &text) {
Ok(()) => info!("wmo cull trace written to {PROBE_DUMP_PATH}"),
@@ -334,16 +384,27 @@ fn compute_wmo_pvs(
}
}
/// The per-frame [`FloodTrace`] tap: records only the down-ray's in-group seed (the camera's
/// current group), which the interior-fog resolve reads — no duplicate down-ray.
/// The per-frame [`FloodTrace`] tap: the down-ray's in-group seed (the camera's current group), which
/// the interior-fog resolve reads — no duplicate down-ray — plus every portal step's *destination
/// group and narrowed rect*, from which [`ExteriorWindows`] is built.
///
/// The windows come out of the flood for free: it already clips each doorway's polygon to a screen
/// rect and intersects it with the rect it arrived on, which is precisely the accumulated
/// screen-space AABB the reference defers per exterior window. We used to throw that rect away and
/// keep only a boolean (`exterior_visible`, for weather).
#[derive(Default)]
struct SeedTap {
in_group: Option<usize>,
/// `(destination group, rect it was entered on)` for every accepted portal step.
entered: Vec<(usize, Rect)>,
}
impl FloodTrace for SeedTap {
fn seed(&mut self, seeds: DownRaySeeds) {
self.in_group = seeds.in_group;
}
fn entered(&mut self, _from: usize, _portal: u16, to: usize, rect: Rect, _on_plane: bool) {
self.entered.push((to, rect));
}
}
/// A tap on the flood's per-portal decisions. The `()` impl is a no-op the per-frame path uses; the
+85 -36
View File
@@ -1,22 +1,31 @@
//! The **WMO skybox** — the authored sky a building swaps in for the `Light.dbc` gradient dome while
//! the camera stands in one of its own groups.
//!
//! A WMO root can name a skybox model in its **MOSB** chunk, and each group can ask for it with group
//! flag **`0x40000`**. Both halves matter, and the *gate is the flag*: four 1.12 roots name a skybox
//! that no group ever asks for (DireMaul's instance shell, `Stratholme_A`, and the two Sunken Temple
//! roots — whose MOSB isn't even a model path, it's the string "the temple of atal'hakkar"), so a
//! renderer keyed off the chunk alone would paint skies the reference never shows.
//! A WMO root can name a skybox model in its **MOSB** chunk, and a group can ask for it with group
//! flag **`0x40000`**. Both halves matter, and the flag is tested **on the groups the portal flood
//! REACHES, never on the group the camera stands in** — `0x6b42e0` sits inside the flood `0x6b41c0`
//! with `ebx` = the group being *visited*, seeded from the containment list on the inside leg and
//! from every frustum-visible EXTERIOR group on the outside leg (`0x6b3dd0`), recursing through
//! portals at `0x6b4639` and re-testing at each step. The predicate is therefore
//! *"any flood-reached group carries the bit, and the root names a MOSB"*, published to `[0xca8080]`
//! and read once at `0x681282`.
//!
//! `0x40000` is undocumented, so its identity rests on a corpus correlation rather than a citation:
//! across all 815 WMO roots in the 5875 chain the bit **never** appears on a group whose root has no
//! MOSB (`benilla-extract skyboxscan` prints that cross-tab, so the claim is re-checkable against the
//! shipped files rather than trusted from here). Five roots exercise both halves: the four Caverns of
//! Time shells (unreleased in 1.12) and **`Stratholme_B`**, the burning city — which sets the bit on
//! 61 of its 83 groups. Those 61 are the city's open streets, which the WMO authors as INTERIOR
//! groups: standing in King's Square you are, to the client, indoors in a building whose "ceiling" is
//! a painted sky. That is why Stratholme's sky is red and the zone light says otherwise — map 329's
//! only reachable `Light.dbc` atmosphere (the global row 341 → `LightParams` 336) is a khaki-brown
//! gradient with a near-black apex, and it is simply not what the reference draws in there.
//! **That distinction is load-bearing, and getting it wrong is what shipped first.** In Stratholme's
//! King's Square the camera stands in group 39 — the root's *only* EXTERIOR (`0x8`) group, which does
//! **not** set `0x40000`. A containing-group test draws no skybox there; the reference draws the
//! painted red sky, because 61 of the 83 groups its flood reaches from group 39 do carry the bit.
//! (Checked on the asset: a BFS over `Stratholme_B`'s MOPR reaches 82 of 83 groups from group 39.)
//! The corpus correlation that seeded the first attempt — across all 815 roots the bit never appears
//! without a MOSB, 0 counter-examples (`benilla-extract skyboxscan`) — was true and still is; it just
//! never established *which* group the renderer tests, and it was over-read as if it had.
//!
//! Five roots exercise both halves: the four Caverns of Time shells (unreleased in 1.12) and
//! **`Stratholme_B`**, the burning city. Four more name a skybox no group ever asks for (DireMaul's
//! instance shell, `Stratholme_A`, and the two Sunken Temple roots — whose MOSB isn't even a model
//! path, it's the string "the temple of atal'hakkar"), so keying off the chunk alone would still
//! paint skies the reference never shows. This is why Stratholme's sky is red where the zone light
//! says otherwise: map 329's only reachable `Light.dbc` atmosphere (global row 341 → `LightParams`
//! 336) is a khaki-brown gradient with a near-black apex, and it is not what draws in there.
//!
//! What we draw is the model as authored: `StratholmeSkybox.m2` is a static, emissive, two-sided cube
//! (three batches × 8 verts, one texture pair per axis), anchored at the camera with identity rotation
@@ -24,11 +33,17 @@
//! every sky element it forces the far depth (`wmo_skybox.wgsl`; the law is in [`crate::sky_order`]),
//! so the world paints over it and the 26-yard shell is free to sit inside the room's own geometry.
//!
//! **Scope.** This swaps the *backdrop* only. The celestial layer above it (sun, moon, stars, cloud
//! dome) and everything the atmosphere drives (fog colour and distance, ambient, diffuse) are
//! untouched — no byte law says the flag reaches them, and the reference shot shows a sun disc over
//! the red sky. If the painted art should also suppress the procedural cloud dome, that is a separate
//! finding and a separate change.
//! **Scope — the skybox replaces the WHOLE celestial pass, not just the backdrop.** `CSky::Render`
//! carries one shared boolean (`0x6d49cd` sets it, `0x6d49fb`/`0x6d4a2e` clear it once any slot's
//! weight exceeds `[0x808aac]` = 0.99) and `0x6d4a3b test edi,edi; je` skips **all six** element
//! draws together — stars, sun disc, both moons, gradient band and cloud dome. There is no
//! per-element gating. Confirmed in a live GL capture of the reference standing in King's Square:
//! the entire `[0.975, 0.98]` sky slice is three `count=12` draws (the cube's three texture pairs)
//! and nothing else, identically in every frame. Only the **glare** quads survive — they render on
//! their own later path (`0x483740 → 0x6d48c0 → 0x7e57e0`), outside this pass.
//!
//! What the atmosphere drives (fog colour and distance, ambient, diffuse) is still untouched: no byte
//! law says the flag reaches it, and the reference fogs the world normally underneath the painted sky.
use std::collections::HashSet;
@@ -46,7 +61,6 @@ use bevy::shader::ShaderRef;
use crate::assets::{LockRecover, WorldAssets};
use crate::player::WorldCamera;
use crate::wmo_portal::CameraInteriorClaim;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::WmoModel;
@@ -138,22 +152,46 @@ impl Plugin for WmoSkyPlugin {
}
}
/// Which skybox does the camera's room ask for? The claim already names the room (placement + group);
/// the group's `SHOW_SKYBOX` flag and the root's MOSB decide the rest.
/// Which skybox does this frame ask for? **The flag is tested on the groups the portal flood
/// REACHES, never on the group the camera stands in** (`0x6b42e0`, inside the flood `0x6b41c0`, with
/// `ebx` = the group being visited). So the predicate is: *does any group in this placement's PVS
/// carry `SHOW_SKYBOX`, and does its root name a MOSB?*
///
/// That distinction is the whole bug this replaced. Reading the **containing** group instead is
/// wrong twice over at Stratholme's King's Square: the camera stands in group 39, the root's only
/// EXTERIOR (`0x8`) group, which does not set `SHOW_SKYBOX` — and [`CameraInteriorClaim`] is
/// deliberately `None` over an EXTERIOR group's floor, so the old resolve could not even see the
/// placement. The reference draws the painted sky there regardless, because 61 of the 83 groups the
/// flood reaches from group 39 do carry the bit. (Verified on the asset: BFS over `Stratholme_B`'s
/// MOPR reaches 82 of 83 groups from group 39, 61 of them flagged.)
fn resolve_camera_skybox(
claim: Res<CameraInteriorClaim>,
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
mut want: ResMut<CameraWmoSkybox>,
) {
let resolved = claim.0.and_then(|c| {
let inst = instances.get(c.room.instance).ok()?;
let model = wmos.get(&inst.handle)?;
let nav = model.group_nav.get(c.room.group as usize)?;
(nav.flags & SHOW_SKYBOX != 0)
.then(|| model.skybox.clone())
.flatten()
});
// `min()` rather than "first match": `Query` iteration order is not stable across frames, and a
// tie would otherwise alternate two backdrops frame to frame. Only one 1.12 root can qualify
// (Stratholme_B — the four Caverns of Time shells are unreleased), so this never actually picks.
let resolved = instances
.iter()
.filter_map(|inst| {
let model = wmos.get(&inst.handle)?;
// The MOSB test comes first and exits ~every instance in one deref: 810 of the chain's
// 815 roots name no skybox, so the group scan below is effectively never reached.
let sky = model.skybox.as_deref()?;
model
.group_nav
.iter()
.enumerate()
.any(|(i, nav)| {
// NOT the fail-open `unwrap_or(true)` the rest of the cull takes
// ([`WmoGroupVis::drawn_by`]): there a lookup miss must never blank a building,
// whereas here it would paint a full-screen sky over the entire world.
nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false)
})
.then(|| sky.to_owned())
})
.min();
if want.0 != resolved {
want.0 = resolved;
}
@@ -259,10 +297,21 @@ fn apply_skybox_visibility(
///
/// The art is drawn at **authored scale** and that is deliberate. Every other shell here scales to a
/// fraction of the far plane, but those radii were only ever standing in for occlusion, and the forced
/// far depth ([`crate::sky_order`], "The depth law") retired that job. What is left for a
/// camera-anchored box to get wrong is the near plane — and Stratholme's is ~26 yd of half-extent
/// against a near plane three orders of magnitude smaller. Scaling it would change nothing on screen
/// (a camera-centred box has no parallax) while quietly making a radius load-bearing again.
/// far depth ([`crate::sky_order`], "The depth law") retired that job. Scale is genuinely free here:
/// the eye's offset inside the box scales with the box, so the *angles* the faces subtend — which is
/// all a camera-anchored backdrop can show — are scale-invariant.
///
/// **The ANCHOR POINT is not free — and it is the model's ORIGIN, VERIFIED.** `CM2Model+0xbc` stays
/// identity and `0x707680` is called with a **zeroed** recentre vector (`0x6d4b3a`–`0x6d4b48`) where
/// the world M2 scene gets the live camera position — so the model's local origin sits exactly at the
/// eye, with camera rotation and zero translation. (The capture agrees: the skybox's matrix is
/// orthonormal with translation `(0,0,0)` while the very next model's carries a real offset.)
///
/// It matters because `StratholmeSkybox` is *not* centred on its origin: its 52.87 yd cube sits at
/// z ∈ [−14.67, +38.20], leaving the eye 11.76 yd BELOW the box's centre. That asymmetry is authored,
/// not incidental — the near-black ±Z pair covers only a **34.7°** cone about the zenith rather than
/// a symmetric 45°, and the side pairs' painted horizon lands three-quarters down their gradient
/// (v ≈ 0.72). Re-centring the box on the eye would be a visible change and a wrong one.
#[allow(clippy::type_complexity)]
fn follow_camera(
cam: Query<&GlobalTransform, With<WorldCamera>>,