your own name survives the first login, and four cold pipelines warm

- your character's own name no longer goes missing on the first login of a fresh start, so KLHThreatMeter stops erroring: the four "player" verbs read the login record, as the reference does (2260, 2261, 2263)
- four pipelines that compiled live mid-play now warm under the loading screen: the model tile atlas's orthographic view, the minimap composite's quad, the glow combine pair and the indoor pool's liquid layout (2262, 2264)
- a hoverable sign seats its tooltip where its template says, at the cursor or in the corner, not by its object type: Brill's signpost still follows the cursor (2259)
- three warnings that fired when nothing was wrong are quiet, and the lane-coverage gates walk every crate instead of one (2264)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:37:17 +07:00
co-authored by Claude Fable 5.1
parent e0f752eb73
commit cbe8c8557c
25 changed files with 1426 additions and 96 deletions
+78
View File
@@ -45,6 +45,22 @@ pub(crate) struct GoTemplate {
/// (`0x5f6d80` — byte-for-byte the same shape, a different slot). `false` for every other type,
/// which never consults it.
pub(crate) highlight_column: bool,
/// GENERIC(5) only: the template's **`data[0]`**, the sole input of that type's `[vtbl+0x5c]`
/// (`0x5f8630`) — the fork in the mouseover publisher that decides whether this object's
/// tooltip FOLLOWS THE CURSOR (`0x492a01` → `0x52ffe0(owner, 6, 0, 0)`, the SetOwner core at
/// anchor state 6) or takes the default corner seat like a creature's (`0x492a42`, which
/// writes no owner and fires `OnTooltipSetDefaultAnchor` instead).
///
/// The slot is named by `0x621b00(type, semantic 0x13)`, a key that resolves for exactly one
/// of the 31 GO types — so no other type can take the cursor arm, and 0766's INTERIM "GENERIC
/// vs not" proxy is replaced by the real key (decision 2259). **Distinct from
/// [`Self::highlight_column`]**, which is `data[1]` via semantic `0x12` and gates *eligibility*
/// rather than *placement*: an object can be hoverable and still be corner-seated.
///
/// `false` for every other type. (The *name* is inferred from vmangos's field order; the index
/// and the fork are byte-verified, and the data is cross-checked against the reference client's
/// own `gameobjectcache.wdb` — 1890 entries, zero disagreements with the server's.)
pub(crate) floating_tooltip: bool,
/// MEETINGSTONE (type 23) only: the template's `data[2]` = **areaID**, the sole input of that
/// type's own `highlightable` slot (`0x5f6990` — decision 1106). `None` for every other type.
pub(crate) meeting_stone_area: Option<u32>,
@@ -124,6 +140,10 @@ impl GameObjectTemplates {
29 => data[19] != 0,
_ => false,
};
// GENERIC(5): data[0] = the tooltip-placement fork's input (decision 2259). Same shape as
// the highlight column above and deliberately beside it — they are adjacent slots that
// answer different questions, and reading them as one is the mistake 0766 made.
let floating_tooltip = type_id == 5 && data[0] != 0;
// MEETINGSTONE (23): data[2] = areaID (vmangos `gameobject_template`), the one input of
// that type's own highlightable slot.
let meeting_stone_area = (type_id == 23).then(|| data[2].max(0) as u32);
@@ -150,6 +170,7 @@ impl GameObjectTemplates {
lock_id,
name,
highlight_column,
floating_tooltip,
meeting_stone_area,
mo_transport,
text_page,
@@ -269,4 +290,61 @@ mod tests {
);
assert_eq!(t.get(guid).map(|g| g.lock_id), Some(38));
}
/// **`data[0]` and `data[1]` are different questions, and a GENERIC object can answer them
/// differently** — the pin that replaces 0766's INTERIM proxy (decision 2259).
///
/// `data[1]` (semantic `0x12`, `0x5f4830`) is mouseover ELIGIBILITY: is this hoverable at all.
/// `data[0]` (semantic `0x13`, `0x5f8630`) is PLACEMENT: does its plate follow the cursor or
/// take the corner seat. Reading one as the other is exactly the mistake 0766 named as a proxy.
///
/// The two rows are real, taken from the reference client's own `gameobjectcache.wdb`
/// (build 5875): `Brill` (entry 1630) is hoverable *and* cursor-seated, while
/// `Doodad_WoodSignPointerNice10` (175656) — the same family of wooden pointer sign — is
/// hoverable and CORNER-seated. Same model family, opposite answers, which is why the type
/// alone could never have been the key.
#[test]
fn a_generic_can_be_hoverable_and_still_corner_seated() {
let mut t = GameObjectTemplates::default();
let mut brill = [0i32; 24];
(brill[0], brill[1]) = (1, 1);
let mut pointer = [0i32; 24];
(pointer[0], pointer[1]) = (0, 1);
t.insert(1630, 5, "Brill".into(), &brill);
t.insert(175656, 5, "Doodad_WoodSignPointerNice10".into(), &pointer);
let brill = t.templates.get(&1630).expect("Brill cached");
assert!(brill.highlight_column, "Brill is hoverable");
assert!(brill.floating_tooltip, "Brill's plate follows the cursor");
let pointer = t.templates.get(&175656).expect("the pointer sign cached");
assert!(
pointer.highlight_column,
"the pointer sign is hoverable too"
);
assert!(
!pointer.floating_tooltip,
"but its plate takes the corner seat — eligibility did not decide placement"
);
}
/// The fork's key exists for GENERIC(5) and for no other type: `0x621b00` answers semantic
/// `0x13` for exactly one of the 31 types, so `data[0]` is never read as a placement flag
/// anywhere else — a CHEST's `data[0]` is its lockId, and must not move its tooltip.
#[test]
fn no_other_type_reads_data0_as_the_placement_fork() {
let mut t = GameObjectTemplates::default();
let mut data = [0i32; 24];
data[0] = 1;
for type_id in [0u32, 1, 2, 3, 6, 7, 8, 9, 10, 16, 22, 23, 25, 29] {
t.insert(9000 + type_id, type_id, format!("type {type_id}"), &data);
assert!(
!t.templates
.get(&(9000 + type_id))
.expect("cached")
.floating_tooltip,
"type {type_id} must not take the cursor arm — it carries no semantic 0x13"
);
}
}
}
+3 -2
View File
@@ -118,8 +118,9 @@ pub(super) fn setup_composite(
let image = images.add(image);
// A 1×1 quad centred on the origin: every tile is this mesh under its own Transform, so a
// panning composite never rewrites a vertex buffer (1463).
let quad = meshes.add(Rectangle::new(1.0, 1.0));
// panning composite never rewrites a vertex buffer (1463). Its layout is a pipeline key axis
// and pipe_warm warms the same mesh, so the builder has one author (2262).
let quad = meshes.add(crate::ui_pass::tile_quad_mesh());
let camera = commands
.spawn((
+1 -1
View File
@@ -129,7 +129,7 @@ fn texel_stretch(extent_yd: f32) -> f32 {
/// above `224/255` (wow-re `system/minimap/scratch/wmo-interior-minimap-composite.md`, VERIFIED).
/// Written as the exact f32 the client computes rather than the ratio, because that ULP is the
/// value fragments are compared against.
const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2);
pub(crate) const INTERIOR_TILE_ALPHA_REF: f32 = f32::from_bits(0x3F60_E0E2);
/// The corpse blip's edge as a fraction of the widget side (the POIIcons cell is authored 16px on
/// a 140px minimap ≈ 0.11; INTERIM eyeball beside [`ARROW_FRACTION`]'s).
+141 -6
View File
@@ -104,12 +104,16 @@ fn load_name_cache(
match NameCache::from_tsv(&text, &realm) {
Some(loaded) => {
let n = loaded.len();
// Replace rather than merge: this is the *start* of a realm's session, so there is
// nothing of this realm's in memory to preserve, and a merge would silently keep the
// previous realm's records alive under their own keys. (What it replaces is creature
// templates — the file carries nothing else since 2223 — and world entry clears the
// guid-keyed stores a moment later regardless.)
*names = loaded;
// **The file's own records go in; nothing else is touched** (decision 2260). This used
// to be `*names = loaded`, on the premise stated here that "world entry clears the
// guid-keyed stores a moment later regardless" — and the ordering is the other way
// round. This loader fires the first frame `identity` answers, which is the frame the
// *pick* is in flight; `connected` does its world-session clear and then seeds our own
// name in the same neighbourhood. Whichever ran second won, and when it was this one
// the player's own name went with it — `UnitName("player")` nil for a wire round-trip,
// once per process, on the first login of a fresh start. See
// [`NameCache::install_persisted`].
names.install_persisted(loaded);
file.saved_generation = names.generation();
debug!(
"names: loaded {n} cached records for {realm} from {}",
@@ -165,3 +169,134 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) {
Err(e) => warn!("names: cannot write {}: {e}", path.display()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// A roster with a pick in flight and no realm — [`crate::ui_macro::identity`] then answers
/// `("Realm", <name>)`, which is the identity the loader keys the file by.
fn roster_with_pick(name: &str, guid: u64) -> Roster {
Roster::with_pending_pick(
vec![benilla_protocol::Character {
guid,
name: name.into(),
race: 1,
class: 1,
gender: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
level: 1,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}],
guid,
)
}
/// **The director's report, at the seam that produced it** (decision 2260).
///
/// The realm cache is read off disk the first frame the pick's identity is known, and the
/// login's seed of our OWN name lands in the same neighbourhood — so the two race, and the
/// loader used to win by overwriting the whole cache (`*names = loaded`). What that cost was
/// `UnitName("player")` answering **nil** for one wire round-trip on the first login of a
/// fresh start, because the feed's player snapshot carries the cache's answer and a `None`
/// there replaces the roster seat the UI loaded under. KLHThreatMeter reads it on its first
/// `OnUpdate` and indexes a table with it: `KTM_Tables.lua:198: table index is nil`.
///
/// The load runs here *after* the seed on purpose: that is the losing order, and it must now
/// be survivable rather than merely unlikely.
#[test]
fn the_disk_load_installs_templates_without_touching_the_live_session() {
use benilla_protocol::guid;
/// `counter | (entry << 24) | (high << 48)` — the server's own composition.
fn compose(high: u16, entry: u32, counter: u32) -> u64 {
u64::from(counter) | (u64::from(entry) << 24) | (u64::from(high) << 48)
}
const CREATURE_ENTRY: u32 = 1234;
const PET_NUMBER: u32 = 7;
let me = compose(guid::HIGH_PLAYER, 0, 0x2A);
let pet = compose(guid::HIGH_PET, PET_NUMBER, 9);
let guard = compose(guid::HIGH_UNIT, CREATURE_ENTRY, 1);
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-namecache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let _capture = EnvGuard::unset("WOW_CAPTURE");
let _home = EnvGuard::set("BENILLA_HOME", tmp.to_str().expect("utf-8 temp path"));
// A realm file with one creature template in it — written the way the saver writes it, so
// the header the loader gates on is this build's by construction.
let mut on_disk = NameCache::default();
on_disk.insert_creature(
CREATURE_ENTRY,
Some(crate::names::CreatureRecord {
name: "Stormwind Guard".into(),
subname: None,
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
let path = crate::local_state::name_cache_path("Realm").expect("a state folder");
std::fs::create_dir_all(path.parent().expect("cache dir")).expect("cache dir");
std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache");
// The live session: the login has already seeded our own name (and a pet's), exactly as
// `net::apply::session::connected` does a moment after the pick goes out.
let mut names = NameCache::default();
names.insert_player(me, "Nelprifour".into(), None);
names.insert_pet(PET_NUMBER, "Fluffy".into());
let before = names.generation();
let mut app = App::new();
app.insert_resource(roster_with_pick("Nelprifour", me))
.insert_resource(names)
.init_resource::<NameCacheFile>()
.add_systems(Update, load_name_cache);
app.update();
let names = app.world().resource::<NameCache>();
assert_eq!(
names.peek(me),
Some("Nelprifour"),
"the login's seed of our own name must survive the disk load — losing it is the bug"
);
assert_eq!(
names.peek(pet),
Some("Fluffy"),
"…and so must a pet name: the file carries neither, so neither is its to replace"
);
assert_eq!(
names.peek(guard),
Some("Stormwind Guard"),
"the file's own records are what the load is for"
);
assert!(
names.generation() > before,
"a landed record moves the counter the gated feeds watch (1439)"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}
+30
View File
@@ -312,6 +312,36 @@ impl NameCache {
self.generation = self.generation.wrapping_add(1);
}
/// **Install a realm cache read off disk — its CREATURE templates, and nothing else**
/// (decision 2260).
///
/// The persisted file has carried creature templates alone since 2223, so this is the whole of
/// what a load can legitimately say. It used to be spelled `*names = loaded` in
/// [`crate::name_persist::load_name_cache`], which is the same thing only when the loader is
/// the first writer of the session — and it is not. The loader fires the first frame the realm
/// identity is known, which is the frame the *pick* is in flight; the login's
/// `SMSG_..._VERIFY_WORLD` seed of our OWN name ([`Self::insert_player`], from
/// `net::apply::session::connected`) lands in the same neighbourhood, and whichever arrives
/// second wins. When the load won, it discarded the player's own name — so `UnitName("player")`
/// answered nil for one wire round-trip while the feed re-asked for a guid it had just been
/// told about, and every addon reading it in that window saw nil (the KLHThreatMeter
/// `table index is nil` report; reproduced live 3 runs in 4).
///
/// Once per process, because `NameCacheFile::realm` only goes `None` → `Some` once — which is
/// exactly the "first login of a fresh game start, and never again" shape the report named.
///
/// So the rule is not an ordering to get right, it is a *scope*: this touches what the file
/// carries. Dropping the guid-keyed stores across a realm change is
/// [`Self::clear_world_session`]'s, and it runs at every world entry regardless.
///
/// Replace rather than merge — the realm-change case, 2223's own reasoning: the previous
/// realm's templates are not this realm's. The generation ticks because a landed record is
/// exactly what the gated feeds watch for (1439).
pub(crate) fn install_persisted(&mut self, loaded: NameCache) {
self.creatures = loaded.creatures;
self.generation = self.generation.wrapping_add(1);
}
/// The landed-answer counter — see the [`Self::generation`] field.
pub(crate) fn generation(&self) -> u64 {
self.generation
+161 -16
View File
@@ -60,6 +60,9 @@ pub(super) struct WarmLanes<'w> {
/// The image store (0958): the twin booth's render target and the effect-lane warm's
/// stand-in texture are created here for the life of the pass.
pub(super) images: ResMut<'w, Assets<Image>>,
/// The UI quad store (2262): the minimap interior composite's tile material is built here
/// through its own production builder, for the tile-quad warm rig below.
ui_quads: ResMut<'w, Assets<crate::ui_pass::UiQuadMaterial>>,
}
/// One portrait/paperdoll booth camera + its layer ([`crate::portrait`], 0938): the booths run
@@ -95,6 +98,7 @@ pub(super) fn spawn_menagerie(
cam: Entity,
booth: Option<(Entity, &bevy::camera::visibility::RenderLayers)>,
warm_booth: &(Entity, bevy::camera::visibility::RenderLayers),
warm_ortho: &(Entity, bevy::camera::visibility::RenderLayers),
meshes: &mut Assets<Mesh>,
materials: &mut Assets<WowModelMaterial>,
lanes: &mut WarmLanes,
@@ -383,6 +387,14 @@ pub(super) fn spawn_menagerie(
// (the class real bakes install) — that otherwise compiles live on the first in-world
// portrait. A unit's gear (any family, any layout, far-swapped included when submerged)
// can reach a booth pane. Shard rows can't (particle instances never ride booth layers).
//
// …and onto the ORTHOGRAPHIC twin (2262), the third projection class: the UI model tile
// atlas's camera. Its rigs are built by `ui_models` through `portrait::material_variant`,
// which clones the world material and swaps only the light buffer — the same pipeline key,
// under a class nothing warmed. **Minus the far twins**, which the booths do take: a far
// twin is `model_render::classify_water_side` swapping a material for an eye-and-model
// straddle of a water plane, and a tile is a `<Model>` widget's own file on its own stage —
// there is no water plane in a tile and no classify pass runs over one.
for (mesh, aabb, skinned) in &layouts {
for mat in mats.iter().chain(far_mats.iter()) {
spawn_model_rig(commands, cam, None, mesh, aabb, *skinned, mat);
@@ -410,6 +422,18 @@ pub(super) fn spawn_menagerie(
);
count += 1;
}
for mat in &mats {
spawn_model_rig(
commands,
warm_ortho.0,
Some(warm_ortho.1.clone()),
mesh,
aabb,
*skinned,
mat,
);
count += 1;
}
if !*skinned {
for mat in shard_mats.iter().chain(far_shard_mats.iter()) {
spawn_model_rig(commands, cam, None, mesh, aabb, false, mat);
@@ -464,7 +488,8 @@ pub(super) fn spawn_menagerie(
let (plain_mesh, plain_aabb, _) = layouts[0].clone();
let (colours_mesh, colours_aabb, _) = layouts[2].clone();
let posuv = meshes.add(warm_pos_uv_mesh());
let liquid_mesh = meshes.add(warm_liquid_mesh());
let liquid_mesh = meshes.add(warm_liquid_mesh(false));
let liquid_color_mesh = meshes.add(warm_liquid_mesh(true));
// Celestial discs + glares (`sun::setup` quads: position+normal+UV).
for mat in lane_handles(&mut lanes.celestial) {
spawn_lane_rig(
@@ -514,14 +539,40 @@ pub(super) fn spawn_menagerie(
&mut count,
);
}
// Liquid: every kind × fog-block material `setup_liquid` built, on the liquid grid layout.
// Liquid: every kind × fog-block material `setup_liquid` built, on BOTH of the liquid grid's
// layouts. An **interior** WMO pool bakes its `MOMT.diffColor` into a fifth attribute —
// `ATTRIBUTE_COLOR`, `liquid::surface::liquid_bevy_mesh` — which `liquid.wgsl:275` reads behind
// `#ifdef VERTEX_COLORS`: a different shader-def set AND a different vertex buffer, so a
// different pipeline, exactly the way the minimap composite's quad was (2262). The ADT and
// WMO-exterior lanes pass `None` and take the shader's `#else` white. Warming only the
// four-attribute form left the first indoor water pool — a cave, a dungeon, an inn's basin —
// compiling live on the frame it came into view.
for mat in lane_handles(&mut lanes.liquid) {
spawn_lane_rig(commands, cam, None, &liquid_mesh, None, mat, &mut count);
spawn_lane_rig(
commands,
cam,
None,
&liquid_mesh,
None,
mat.clone(),
&mut count,
);
spawn_lane_rig(
commands,
cam,
None,
&liquid_color_mesh,
None,
mat,
&mut count,
);
}
// The plain-`StandardMaterial` lanes (0938 — the director's evening log). The fallback cube
// (`entities::CubeAssets`, drawn while any entity's model streams) uses the production mesh
// + materials, on the world camera AND a booth layer (a cube-bodied target can reach a
// portrait pane). The nameplate and raid-mark materials are built on first need, so the lane
// portrait pane) — but NOT the orthographic twin (2262): `ui_models` has no cube fallback at
// all, a tile's root simply stays bare until its M2 is resident, so no cube ever draws
// through the tile camera. The nameplate and raid-mark materials are built on first need, so the lane
// warms REPRESENTATIVES with the builders' exact key fields (`nameplates::spawn_nameplates`,
// `raid_marks::place_marks` — texture presence is not a key axis); the plate/mark quads
// share the static-plain attribute set.
@@ -590,6 +641,43 @@ pub(super) fn spawn_menagerie(
mark,
&mut count,
);
// The **minimap interior composite's tile quad** (1466, warmed by 2262). A `Material2d`
// pipeline is keyed on `(view key, MESH LAYOUT)`, and `UiQuadMaterial` therefore has TWO
// pipelines, not the one `warm_ui_quad_lane`'s doc claimed: the HUD's batch mesh is
// POSITION+UV_0+COLOR, the composite's tile is `Rectangle` — POSITION+NORMAL+UV_0. The
// composite's own camera cannot be warmed through (it is `is_active: false` until the player
// is inside a baked WMO interior, and an inactive camera is not extracted at all, so there is
// no view to specialise against) — but it does not need to be: the two cameras' VIEW keys are
// byte-identical (both `Msaa::Off`, neither HDR, same tonemap defs — confirmed by diffing the
// two variants in a `WOW_PIPE_TRACE` inventory, which differ in nothing but the vertex defs
// and buffer layout). So drawing the composite's mesh + its production material through the
// player-UI camera mints exactly the composite's pipeline, under the cover.
//
// The texture is a real 1×1 stand-in rather than a default handle, so the material's
// `AsBindGroup` cannot land on the retry path: a `#[texture]` binding whose `Some(handle)`
// does not resolve returns `RetryNextUpdate`, and a warm rig that never prepares is a warm
// rig that draws nothing and warms nothing, silently. `warm_effect_lane` binds a stand-in
// for the same reason.
let tile_tex = lanes.images.add(Image::default());
commands.spawn((
Mesh2d(meshes.add(crate::ui_pass::tile_quad_mesh())),
MeshMaterial2d(
lanes
.ui_quads
.add(crate::ui_pass::UiQuadMaterial::interior_tile(
tile_tex,
crate::minimap::INTERIOR_TILE_ALPHA_REF,
)),
),
// Visible — unlike the model rigs, which the pacing reveals a slice at a time. It is one
// pipeline, drawn once at a scale that covers no pixel worth speaking of.
Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(0.001)),
crate::ui_pass::ui_render_layers(),
WarmRig,
));
count += 1;
count
}
@@ -705,9 +793,16 @@ fn warm_pos_uv_mesh() -> Mesh {
m
}
/// A tiny quad in the liquid grid's layout — POSITION + NORMAL + UV_0 + UV_1
/// (`liquid::surface` inserts exactly these four attributes).
fn warm_liquid_mesh() -> Mesh {
/// A tiny quad in the liquid grid's layout — POSITION + NORMAL + UV_0 + UV_1, plus
/// `ATTRIBUTE_COLOR` when `body_color` is set.
///
/// **Both forms are real** (2262's lesson, found by the sweep after it): `liquid_bevy_mesh` takes
/// an `Option<[f32; 3]>` body colour and inserts the fifth attribute only for an INTERIOR WMO
/// pool that is not fullbright — the ADT and WMO-exterior lanes pass `None`. `liquid.wgsl:275`
/// reads it behind `#ifdef VERTEX_COLORS`, so the two are separate pipelines. This doc used to
/// claim `liquid::surface` "inserts exactly these four attributes", which is what kept the second
/// one out of the menagerie.
fn warm_liquid_mesh(body_color: bool) -> Mesh {
let mut m = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
@@ -725,6 +820,10 @@ fn warm_liquid_mesh() -> Mesh {
m.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]);
m.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs.clone());
m.insert_attribute(Mesh::ATTRIBUTE_UV_1, uvs);
if body_color {
// The production insert's shape exactly (`liquid_bevy_mesh`): one RGBA per vertex, alpha 1.
m.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[1.0, 1.0, 1.0, 1.0]; 4]);
}
m.insert_indices(Indices::U32(vec![0, 1, 2, 0, 2, 3]));
m
}
@@ -806,13 +905,20 @@ mod tests {
// (Its world-lane twin, benilla-world's `FfxCombinePipeline`, is outside this scan
// and compiles under the same cover: the player-UI camera's backdrop pair is keyed on
// that camera's own main texture and specialised on its first frame, pre-world, and a
// bake's pair on the bake image's fixed format.)
// bake's pair on the bake image's fixed format. That list was one short — a world view
// also has its OWN pair, for the frames nothing claims it, and 2262 found it compiling
// live at app exit. `prepare_textures` now specialises that pair on every frame rather
// than only on the frame it first needs it.)
let exempt = ["UiGammaPipeline"];
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap()
+ &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap();
let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap()
+ &std::fs::read_to_string(own_src.join("pipe_warm/menagerie.rs")).unwrap();
let mut missing = Vec::new();
for (path, text) in walk_rs(&src_root) {
let files: Vec<_> = workspace_src_roots()
.iter()
.flat_map(|r| walk_rs(r))
.collect();
for (path, text) in files {
for needle in [
"impl SpecializedRenderPipeline for ",
"impl SpecializedMeshPipeline for ",
@@ -838,6 +944,41 @@ mod tests {
);
}
/// **The scan root: every crate in the workspace, not just this one.**
///
/// Both lane-coverage scans below used to walk `CARGO_MANIFEST_DIR/src` — which is
/// `crates/benilla-app/src`, and every lane either scan exists to catch lives in
/// **`benilla-world`**: all eight 3-D `MaterialPlugin` registrations, and both real hand-rolled
/// pipelines (`FfxCombinePipeline`, `EffectPipeline`). The only `Specialized*Pipeline` impl in
/// benilla-app is `UiGammaPipeline`, which the test then exempts — so the custom-lane assertion
/// could not fail, ever, and the material assertion could only ever see the two 2-D/UI
/// families. Both tests were green because they were looking at an empty room; the exempt list
/// even named `TerrainMaterial`/`WdlMaterial`, types the walk could never reach. Found by the
/// sweep after 2262, which is the third time in this file's history that a lane shipped
/// unwarmed because the instrument could not see it.
fn workspace_src_roots() -> Vec<std::path::PathBuf> {
// `crates/benilla-app` → `crates`, then every crate's `src` under it.
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("CARGO_MANIFEST_DIR always has a parent")
.to_path_buf();
let mut roots: Vec<std::path::PathBuf> = std::fs::read_dir(&crates)
.expect("the crates/ directory is always readable from a test")
.filter_map(|e| {
let p = e.ok()?.path().join("src");
p.is_dir().then_some(p)
})
.collect();
roots.sort();
assert!(
roots.len() > 1,
"the lane scans must walk the whole workspace — a single root is the bug this \
function exists to prevent (only {roots:?} found under {})",
crates.display(),
);
roots
}
/// Every `.rs` file under `src`, read — shared by both lane-coverage scans.
fn walk_rs(src_root: &std::path::Path) -> Vec<(std::path::PathBuf, String)> {
let mut out = Vec::new();
@@ -882,12 +1023,16 @@ mod tests {
("Material2dPlugin::<", &[]),
("UiMaterialPlugin::<", &["AddUiMaterial"]),
];
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let own_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
// "Named in this file" = anywhere in the pipe_warm module folder.
let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap()
+ &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap();
let warm_src = std::fs::read_to_string(own_src.join("pipe_warm/mod.rs")).unwrap()
+ &std::fs::read_to_string(own_src.join("pipe_warm/menagerie.rs")).unwrap();
let mut missing = Vec::new();
for (path, text) in walk_rs(&src_root) {
let files: Vec<_> = workspace_src_roots()
.iter()
.flat_map(|r| walk_rs(r))
.collect();
for (path, text) in files {
for (needle, exempt) in families {
for (i, _) in text.match_indices(needle) {
// A preceding ident char means this match is really a longer family's name
+255 -6
View File
@@ -110,7 +110,15 @@ pub(crate) fn plugin(app: &mut App) {
});
app.insert_resource(PipeWatch(shared.clone()));
app.init_resource::<WarmPass>();
app.add_systems(Last, (publish_cover, publish_compile_burst));
app.add_systems(
Last,
(
publish_cover,
publish_compile_burst,
record_warmed_views,
census_view_classes,
),
);
// Before the Present stage so the loading screen reads this frame's gate, not last frame's.
app.add_systems(
Update,
@@ -321,6 +329,15 @@ pub(crate) struct WarmPass {
/// LIVE" warn is exactly the instrument for it — which is why this can be a latch and not a
/// guess. A timeout does NOT latch it: something was still pending.
warmed_once: bool,
/// **The cameras the menagerie parents rigs to** — the world camera, one real portrait booth,
/// the twin booth and the orthographic twin. Recorded as entities at spawn so
/// [`record_warmed_views`] can read their LIVE view key each frame instead of restating what
/// the spawn code meant (2264).
anchors: Vec<Entity>,
/// **The view keys those anchors actually carried while the pass ran** — the census's only
/// notion of "warm". Never a rule, never an inference: if a rig did not render through it,
/// it is not in here.
warmed_views: Vec<ViewClass>,
/// Pacing state (1116): rigs warmed so far, when the last slice went out, how many frames
/// the reveal spanned, and the slice currently on screen (hidden again next frame).
revealed: usize,
@@ -461,13 +478,23 @@ fn run_warm_pass(
commands
.entity(warm_booth.0)
.insert((WarmRig, WarmBoothCam));
// The orthographic twin (2262): the THIRD projection class, the one the UI model tile
// atlas draws through. Same deal — a WarmRig, so the despawn paths take it too.
let warm_ortho = crate::ui_models::spawn_warm_tile_cam(&mut commands, &mut lanes.images);
commands.entity(warm_ortho.0).insert(WarmRig);
// The effect lane's stand-in texture — held for the life of the pass.
warm.effect_tex = Some(lanes.images.add(Image::default()));
// The anchors the census measures "warm" from (2264): every camera the menagerie actually
// hangs rigs on. Recorded as entities, not as remembered shapes.
warm.anchors = vec![cam, warm_booth.0, warm_ortho.0];
warm.anchors.extend(booth.iter().next().map(|(e, _)| e));
warm.warmed_views.clear();
let count = spawn_menagerie(
&mut commands,
cam,
booth.iter().next(),
&warm_booth,
&warm_ortho,
&mut meshes,
&mut materials,
&mut lanes,
@@ -549,6 +576,149 @@ fn run_warm_pass(
}
}
/// **The view-class census** (2262, rebuilt by 2264) — 0958's claim, turned from a sentence into
/// an instrument that can actually fire.
///
/// 0958 verified that "the whole 3-D view space is `(samples, projection class)`, and both classes
/// of both sample counts are now warm". True the day it was written; false a month later, when
/// 2013 gave the UI model tile atlas an orthographic camera — a third class, warmed by nothing,
/// whose first tile compiled its whole batch set live. Neither lane-coverage gate test can see a
/// *camera*, so nothing caught it.
///
/// **2262's first attempt at this census could not fire.** It decided what was warm from a rule it
/// held in its own head — "samples=1 is warm in all three classes, and the world camera's own pair
/// is warm" — and then skipped every camera matching it. But `gxMultisample` registers `"1"`
/// (`cvars.rs`), `MsaaSetting::default()` is 1, and **only the world camera ever reads it**
/// (`player/setup.rs:99`, `:118`); every other 3-D camera hard-codes `Msaa::Off` through
/// `booth_view_shape()`. So `samples == 1` covered the entire population and the loop body was
/// unreachable — including for the very camera 2013 added. An instrument that restates what the
/// author believed is not an instrument.
///
/// So it no longer believes anything. [`WarmPass::warmed_views`] records the view key of each
/// camera the menagerie **actually parented rigs to**, read off those cameras live while the pass
/// runs; the census compares every `Camera3d` against that recorded set and nothing else. It also
/// runs for the whole session rather than once at drain, warning once per distinct unwarmed key,
/// because the class a booth is warm in is one it installs *at runtime* on its first bake — which
/// a single read at drain is too early to see.
fn census_view_classes(
warm: Res<WarmPass>,
mut reported: Local<Vec<ViewClass>>,
cams: ViewCamQuery,
) {
if !warm.warmed_once {
return;
}
for (name, projection, msaa, hdr) in &cams {
let class = view_class(projection, msaa, hdr);
if warm.warmed_views.contains(&class) || reported.contains(&class) {
continue;
}
reported.push(class);
warn!(
"pipeline warm: camera {} draws through {} — a view key the menagerie never rendered \
a rig through, so its whole model-pipeline space compiles LIVE on first sight. Give \
it a warm arm (decisions 0958/2262/2264). Warm keys this session: {}.",
name.map_or("<unnamed>", Name::as_str),
class.describe(),
warm.warmed_views
.iter()
.map(ViewClass::describe)
.collect::<Vec<_>>()
.join(", "),
);
}
}
/// Every 3-D camera in the world, for [`census_view_classes`]: the name it reports itself by, and
/// the three components that decide its mesh-pipeline view key.
type ViewCamQuery<'w, 's> = Query<
'w,
's,
(
Option<&'static Name>,
Option<&'static Projection>,
Option<&'static Msaa>,
Has<bevy::render::view::Hdr>,
),
With<Camera3d>,
>;
/// The key a 3-D view contributes to `MeshPipelineKey`, as [`census_view_classes`] compares them.
///
/// `hdr` is here because it is a key bit in its own right AND the gate on two more: bevy admits
/// `TONEMAP_IN_SHADER` and `DEBAND_DITHER` into the key only when the view is **not** HDR
/// (`bevy_pbr-0.18.1` `render/mesh.rs:418`), and `Camera3d`'s required components hand a camera
/// both by default. 0958's "every 3-D camera is HDR so tonemap/dither are dead axes" is true of
/// every camera we spawn today and is exactly the kind of sentence this census exists to stop
/// trusting: a new 3-D camera that forgets `Hdr` flips three key bits at once, and 2262's version
/// did not look at it.
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub(crate) struct ViewClass {
projection: &'static str,
samples: u32,
hdr: bool,
}
impl ViewClass {
fn describe(&self) -> String {
format!(
"{}/samples={}/{}",
self.projection,
self.samples,
if self.hdr { "hdr" } else { "no-hdr" }
)
}
}
fn view_class(projection: Option<&Projection>, msaa: Option<&Msaa>, hdr: bool) -> ViewClass {
ViewClass {
// bevy_pbr folds exactly these three into `MeshPipelineKey` (`bevy_pbr-0.18.1`
// `render/mesh.rs:397`); the match is exhaustive so a fourth variant upstream stops the
// build here rather than opening a silent hole. A view with no `Projection` at all sets
// no bits, which is the NONSTANDARD pattern — the same class as Custom.
projection: match projection {
Some(Projection::Perspective(_)) => "Perspective",
Some(Projection::Orthographic(_)) => "Orthographic",
Some(Projection::Custom(_)) | None => "Custom",
},
samples: msaa.copied().unwrap_or_default().samples(),
hdr,
}
}
/// The menagerie's anchor cameras, read for their LIVE view key by [`record_warmed_views`] —
/// entity first, so the recorder can match against [`WarmPass::anchors`].
type AnchorViewQuery<'w, 's> = Query<
'w,
's,
(
Entity,
Option<&'static Projection>,
Option<&'static Msaa>,
Has<bevy::render::view::Hdr>,
),
With<Camera3d>,
>;
/// Record the view key of every camera the menagerie actually parents rigs to, while the pass is
/// running. Read off the live cameras rather than restated from what the spawn code intended —
/// that restatement is what made 2262's census unable to fire.
fn record_warmed_views(mut warm: ResMut<WarmPass>, cams: AnchorViewQuery) {
if warm.spawned_at.is_none() || warm.done {
return;
}
let anchors = warm.anchors.clone();
for (e, projection, msaa, hdr) in &cams {
if !anchors.contains(&e) {
continue;
}
let class = view_class(projection, msaa, hdr);
if !warm.warmed_views.contains(&class) {
warm.warmed_views.push(class);
}
}
}
/// Tear the pass down by despawning only its ROOT entities. `despawn` is recursive, and the twin
/// booth's rigs are *children* of the twin booth camera — itself a `WarmRig` — so despawning
/// every query row queues the children twice (once explicitly, once via the parent's recursion):
@@ -576,11 +746,16 @@ fn despawn_rigs(commands: &mut Commands, rigs: &Query<(Entity, Option<&ChildOf>)
/// half exercised too. Per-frame like the gizmo line: the stream clears every frame.
///
/// [`EffectPipelineKey`]: benilla_world::particles::render::EffectPipelineKey
/// The HUD-substrate warm (0958's sweep, residual): `UiQuadMaterial` is exactly ONE pipeline,
/// and on a normal entry it compiles covered because the HUD's first quad batch lands under the
/// cover — but nothing structural holds that timing (a slow Interface load would land it after
/// the lift). One invisible overlay quad per warm frame pins the compile inside the cover
/// window; the append lane clears itself every frame, so nothing lingers.
/// The HUD-substrate warm (0958's sweep, residual): on a normal entry the HUD's first quad batch
/// lands under the cover — but nothing structural holds that timing (a slow Interface load would
/// land it after the lift). One invisible overlay quad per warm frame pins the compile inside the
/// cover window; the append lane clears itself every frame, so nothing lingers.
///
/// This warms the HUD's **batch mesh** layout (POSITION + UV_0 + COLOR) only. 0958 read
/// `UiQuadMaterial` as "exactly ONE pipeline"; it is two, because a `Material2d` pipeline is
/// keyed on the mesh layout as well as the view, and the minimap interior composite draws the
/// same material on a `Rectangle` (POSITION + NORMAL + UV_0). That second one is warmed as a rig
/// in [`menagerie`], not here — this lane's stream only ever builds the batch layout (2262).
fn warm_ui_quad_lane(warm: Res<WarmPass>, mut quads: ResMut<crate::ui_pass::UiQuads>) {
if warm.spawned_at.is_none() || warm.done {
return;
@@ -700,3 +875,77 @@ fn warm_effect_lane(
);
}
}
#[cfg(test)]
mod census_tests {
use super::{view_class, ViewClass};
use bevy::camera::{OrthographicProjection, PerspectiveProjection, Projection};
use bevy::render::view::Msaa;
fn perspective() -> Projection {
Projection::Perspective(PerspectiveProjection::default())
}
fn orthographic() -> Projection {
Projection::Orthographic(OrthographicProjection::default_3d())
}
/// The warm set as it stood the day 2013 landed: the world camera's Perspective and the two
/// `Msaa::Off` booth classes. No orthographic arm — because there was no orthographic camera
/// when 0958 wrote the census down.
fn warm_set_before_2262() -> Vec<ViewClass> {
vec![
view_class(Some(&perspective()), Some(&Msaa::Off), true),
view_class(Some(&perspective()), Some(&Msaa::Sample4), true),
view_class(None, Some(&Msaa::Off), true),
]
}
/// **The census must fire for decision 2013's tile camera.** This is the case 2262's first
/// version could not report: that camera is `Msaa::Off` like every other booth, and 2262
/// skipped `samples == 1` outright, so the loop body was unreachable for it — and for every
/// other camera the client spawns, since `gxMultisample` defaults to 1 and only the world
/// camera ever reads it. The census now compares against the keys rigs actually rendered
/// through, so a class nothing warmed is a class it names.
#[test]
fn the_census_fires_for_an_unwarmed_orthographic_camera() {
let warm = warm_set_before_2262();
let tile_cam = view_class(Some(&orthographic()), Some(&Msaa::Off), true);
assert!(
!warm.contains(&tile_cam),
"the ui_models tile camera's view key must read as unwarmed against a warm set that \
has no orthographic arm — this is the report 2262 was written to produce"
);
// And the rule 2262 actually shipped would have swallowed it.
assert_eq!(
tile_cam.samples, 1,
"the tile camera is Msaa::Off, like every booth"
);
}
/// The other half: a class the menagerie DID render through is silent, so the census cannot
/// cry wolf on the cameras it is meant to bless.
#[test]
fn the_census_is_silent_for_every_warmed_class() {
let warm = warm_set_before_2262();
for class in &warm {
assert!(warm.contains(class));
}
let booth_after_first_bake = view_class(None, Some(&Msaa::Off), true);
assert!(
warm.contains(&booth_after_first_bake),
"a booth's runtime-installed custom projection is the NONSTANDARD class, warmed by \
the twin booth (0958)"
);
}
/// `Hdr` is a key bit and the gate on two more (tonemap, deband — `bevy_pbr` `mesh.rs:418`).
/// 2262's census did not read it, so a 3-D camera that forgot `Hdr` was invisible to it.
#[test]
fn dropping_hdr_is_a_different_view_key() {
let with = view_class(Some(&perspective()), Some(&Msaa::Off), true);
let without = view_class(Some(&perspective()), Some(&Msaa::Off), false);
assert_ne!(with, without);
assert!(!warm_set_before_2262().contains(&without));
}
}
+10 -2
View File
@@ -224,12 +224,19 @@ pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1;
/// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs
/// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer.
pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1;
/// pipe_warm's **orthographic twin camera**'s layer ([`crate::ui_models::spawn_warm_tile_cam`],
/// decision 2262) — the next one past the twin booth's, same ladder rule. bevy_pbr keys a mesh
/// pipeline on the view's projection CLASS, and the tile atlas's one camera is
/// `Projection::Orthographic`: a THIRD class beside the world camera's Perspective and the twin
/// booth's custom `WowPortraitProjection`. Like [`WARM_BOOTH_LAYER`] this camera exists only while
/// the warm pass runs, and nothing but menagerie rigs ever rides its layer.
pub(crate) const WARM_ORTHO_LAYER: usize = WARM_BOOTH_LAYER + 1;
/// The **minimap interior composite**'s render layer (decision 1466) — the next one past the warm
/// booth's. Not a portrait booth, but it is an offscreen camera with its own layer, and 0775's rule
/// is that EVERY such layer is computed in this one ladder: the two booths that each worked out
/// "the next layer past the paper doll's" in their own file landed on the same number, and the
/// clash was silent in both rendering and the emitter→camera match.
pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_ORTHO_LAYER + 1;
/// The UI model tiles' layer (`crate::ui_models`, decision 2008): every `<Model>` widget's M2
/// renders into one atlas through one camera on this layer.
pub(crate) const UI_MODELS_LAYER: usize = MINIMAP_COMPOSITE_LAYER + 1;
@@ -262,7 +269,8 @@ const _: () = assert!(
&& INSPECT_LAYER != GLUE_LAYER
&& DRESSUP_LAYER > GLUE_LAYER
&& WARM_BOOTH_LAYER > DRESSUP_LAYER
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER
&& WARM_ORTHO_LAYER > WARM_BOOTH_LAYER
&& MINIMAP_COMPOSITE_LAYER > WARM_ORTHO_LAYER
&& UI_MODELS_LAYER > MINIMAP_COMPOSITE_LAYER
&& UI_MODEL_CAM_LAYER_BASE > UI_MODELS_LAYER,
"booth render layers must be distinct — see GLUE_LAYER"
+16 -2
View File
@@ -762,7 +762,18 @@ pub(super) fn play_kit_ext(
return Ok(false);
}
let mixer = out.mixer.as_mut().context("no audio device")?;
// **No device is a no-op, not an error.** Running silent (`WOW_NOSOUND=1`, CI, an unattended
// probe — or a player whose device is genuinely gone) is a startup fact `sound::plugin`
// already warns about ONCE. Returning `Err` here turned that one fact into a warn PER EVENT
// at every one of this module's ~25 call sites: a 75 s lava probe logged 3975 identical
// `no audio device` lines, 89% of the file, which is why `liquid_loop` grew a guard of its
// own — and a probe sweep still footstepped one warn every half-second. The fix belongs here,
// at the one place that knows, rather than as a guard repeated at each caller. `false` is the
// signal this function already uses for "the gates dropped it", which is exactly what
// happened.
let Some(mixer) = out.mixer.as_mut() else {
return Ok(false);
};
let (track, handle) = match pos {
Some(p) => {
// `EAXDef 0` = no `SoundSamplePreferences` row = the reference's NULL-slot skip at
@@ -984,7 +995,10 @@ pub(crate) fn play_file(
if !claim_voice(out, amp) {
return Ok(());
}
let mixer = out.mixer.as_mut().context("no audio device")?;
// No device is a no-op here too — see the note in [`play_kit_ext`].
let Some(mixer) = out.mixer.as_mut() else {
return Ok(());
};
let handle = mixer.play_2d(data)?;
if let Some(probe) = out.probe.as_ref() {
probe.note_play(0, path, "sfx", "2d");
+57
View File
@@ -755,6 +755,63 @@ fn setup_tiles(
let _ = light_buf;
}
/// pipe_warm's **orthographic twin camera** (decision 2262) — the ortho leg's view key space, the
/// way [`crate::portrait::spawn_warm_booth`] is the custom-projection one (0958).
///
/// bevy_pbr folds the view's projection **class** into `MeshPipelineKey` (`bevy_pbr-0.18.1`
/// `render/mesh.rs:397` — `Perspective | Orthographic | Custom`, emitted as the
/// `VIEW_PROJECTION_*` shader def at `:2549`), so one material is a *different pipeline* per
/// class. 0958's census closed with "the whole 3-D view space is `(samples, projection class)`,
/// and both classes of both sample counts are now warm" — true on 2026-08-04, when the only
/// classes were the world camera's Perspective and the booths' custom `WowPortraitProjection`.
/// Decision 2013 added [`setup_tiles`]' orthographic camera a month later and nothing widened the
/// warm pass, so the first UI model tile of a session — a cooldown pie, a minimap ping, an
/// item-push card — compiled its batches live, uncovered, on the render thread.
///
/// This camera is that missing class in the tile camera's exact shape: [`booth_view_shape`]
/// (`Msaa::Off`, HDR, no tonemap) and `FfxGlow::UI_PANE`, spawned right beside the real one above
/// so the two cannot drift apart. The REAL tile camera is deliberately not borrowed for warming —
/// it is `is_active: false` until a pane packs a cell, and switching it on would draw the whole
/// menagerie into the live atlas. Only the projection's CLASS keys the pipeline, never its
/// numbers; they mirror the real camera's regardless, for the same anti-drift reason.
pub(crate) fn spawn_warm_tile_cam(
commands: &mut Commands,
images: &mut Assets<Image>,
) -> (Entity, RenderLayers) {
let layer = RenderLayers::layer(crate::portrait::WARM_ORTHO_LAYER);
// The atlas's own minimum size, the way `spawn_warm_booth` takes the real booths'. A render
// target's SIZE reaches no pipeline key (the combine pair is keyed on format; mesh pipelines
// specialise at queue time, before anything rasterises), so this could be tiny — and a 64²
// arm was measured against this one: 4.59 s vs 4.61 s of warm drain, i.e. nothing. The pass's
// extra cost is the wider cross it reveals, not the pixels this camera fills, so the size
// stays the one that matches the camera being warmed.
let image = images.add(new_target_image_sized(ATLAS_MIN, ATLAS_MIN));
let cam = commands
.spawn((
Name::new("pipe_warm orthographic twin camera"),
booth_view_shape(),
Camera {
order: TILE_CAMERA_ORDER - 1,
clear_color: ClearColorConfig::Custom(Color::NONE),
..default()
},
RenderTarget::Image(image.into()),
benilla_world::ffx_glow::FfxGlow::UI_PANE,
Projection::Orthographic(OrthographicProjection {
near: 0.1,
far: 2000.0,
scaling_mode: ScalingMode::Fixed {
width: ATLAS_MIN as f32,
height: ATLAS_MIN as f32,
},
..OrthographicProjection::default_3d()
}),
layer.clone(),
))
.id();
(cam, layer)
}
/// The bevy-space → tile-camera-space rotation: WoW `+X` (bevy `−Z`) to the right, WoW `+Y`
/// (bevy `−X`) up, WoW `+Z` (bevy `+Y`) toward the viewer — the ortho leg's axes (§2). A proper
/// rotation (determinant +1), so winding survives.
+16 -1
View File
@@ -353,7 +353,7 @@ fn clear_ui_overlays(mut quads: ResMut<UiQuads>) {
/// doesn't need a mesh-visible layer at all).
const UI_RENDER_LAYER: usize = 1;
fn ui_render_layers() -> RenderLayers {
pub(crate) fn ui_render_layers() -> RenderLayers {
RenderLayers::layer(UI_RENDER_LAYER)
}
@@ -480,6 +480,21 @@ impl UiQuadMaterial {
}
}
/// The mesh every `UiQuadMaterial` quad that is **not** part of the HUD batch stream draws with:
/// a 1×1 rectangle at the origin, scaled and placed by its own Transform (decision 1466 — a
/// panning composite never rewrites a vertex buffer).
///
/// It has ONE author because a `Material2d` pipeline is keyed on `(view key, MESH LAYOUT)`, and
/// these two quad families have different layouts: `Rectangle` gives POSITION + NORMAL + UV_0
/// (`bevy_mesh` `primitives/dim2.rs`), the HUD's batch mesh POSITION + UV_0 + COLOR
/// ([`rebuild_ui_mesh`]). So `UiQuadMaterial` is **two** pipelines, not one — the claim pipe_warm
/// carried until decision 2262, which is why the composite's compiled live on the first step into
/// a WMO interior. `pipe_warm`'s menagerie warms this exact mesh; drifting it here would take the
/// warm rig with it.
pub(crate) fn tile_quad_mesh() -> Mesh {
Rectangle::new(1.0, 1.0).into()
}
impl Material2d for UiQuadMaterial {
/// Our own vertex stage, in the same file: bevy's carries no per-instance data past the
/// transform, and the run's tint rides the instance tag (see [`tint_tag`]).
@@ -534,7 +534,11 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() {
fn the_roster_seat_names_the_character_the_addons_will_meet() {
let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
assert_eq!(seat.name.as_deref(), Some("Harness"));
assert_eq!(seat.level, 60);
// **0, not 60 — the reference's answer, byte-verified** (decision 2263). `UnitLevel 0x517fc0`
// carries no `"player"` fast path at all: it resolves the token, misses (no object, no roster
// record for a zero GUID) and reaches `0x51813e push 0; push 0` — the NUMBER 0, one return.
// The roster's real level arrives with the descriptor, within the second.
assert_eq!(seat.level, 0);
assert_eq!(seat.race_file.as_deref(), Some("Human"));
assert_eq!(seat.class_file.as_deref(), Some("WARRIOR"));
assert_eq!(seat.sex, 2, "the wire's 0 is UnitSex's 2");
@@ -543,7 +547,17 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() {
Some("Alliance"),
"nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation"
);
assert!(seat.exists && seat.is_player);
// **`exists` is false, and that is the correction 2263 made** (it was `true` from 1230 until
// then). `UnitExists 0x515fb0` has no fast path either; its resolver reads the GUID out of the
// OBJECT (`0x515994`), so with none it holds `0:0`, and the roster fallback `0x491900` bails
// on a zero GUID at `0x4e80aa je` BEFORE fetching the active player — so the `0 == 0` that
// would answer "that's me" never runs, and `0x516001` pushes nil.
//
// Safe to correct only *because* of the record above: 1230 seated a whole unit in order to
// deliver a name at addon file scope, and the name no longer needs a unit to exist. The rest
// of the seat is untouched and still load-bearing — the faction side below above all.
assert!(!seat.exists);
assert!(seat.is_player);
// Deliberately NOT invented — the descriptor says these, within the second.
assert_eq!((seat.health, seat.max_health), (0, 0));
+71 -7
View File
@@ -502,6 +502,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
script.set_realm_name(&realm);
// …and so does the PLAYER, for the same reason and with more riding on it — see
// [`seat_from_roster`], which is where the why lives.
//
// **The player RECORD first, and separately** (decisions 2261/2263). The reference keeps the
// local player's name, race, class and gender somewhere no cache, feed or object can reach — a
// copy of the char-enum row written at the Enter World commit (`0x5abd9e`) and never cleared —
// and `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only that, unconditionally, even after
// the descriptor exists. The snapshot below is the descriptor's stand-in until the real one
// streams in and is replaced when it does; the record outlives every push. Seeded here because
// our VM is rebuilt per login (1290) while the reference's record simply persists, so each new
// VM has to be told once — before the addon walk, like the realm.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
{
script.set_player_record(record);
}
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
@@ -660,6 +675,43 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
}
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s own 2/3 scale — the one mapping, so
/// the record and the snapshot can never disagree about which is which.
fn roster_sex(gender: u8) -> u8 {
match gender {
0 => 2,
1 => 3,
_ => 0,
}
}
/// **The local player record the UI loads under** — our copy of the char-enum row the reference
/// copies at the Enter World commit (decision 2263; the bytes are on
/// [`benilla_ui::script::PlayerRecord`]).
///
/// The same roster row [`seat_from_roster`] builds the `"player"` *snapshot* from, and
/// deliberately the same `race_names`/`class_names` lookups: the reference resolves the record's
/// race and class bytes through `ChrRaces`/`ChrClasses` at call time, so resolving them once here
/// is the same answer, and sharing the lookup is what stops the record and the snapshot drifting
/// into two opinions about what race 4 is.
///
/// **The level is not here, and that is byte-verified, not an omission** — the reference's record
/// carries a level at `+0x108` and ships an accessor for it (`0x5abe00`) that *nothing calls*, so
/// `UnitLevel("player")` reads the descriptor.
pub(crate) fn record_from_roster(
roster: &crate::char_select::Roster,
) -> Option<benilla_ui::script::PlayerRecord> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::PlayerRecord {
name: row.name.clone(),
race: race.map(|(n, f)| (n.to_string(), f.to_string())),
class: class.map(|(n, f)| (n.to_string(), f.to_string())),
sex: roster_sex(row.gender),
})
}
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
///
@@ -693,20 +745,32 @@ pub(crate) fn seat_from_roster(
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
let sex = roster_sex(row.gender);
Some(benilla_ui::script::UnitState {
exists: true,
// **`exists` is FALSE, and the level is 0 — the reference's answers, byte-verified**
// (decision 2263). `UnitExists("player")` `0x515fb0` has no fast path: it resolves the
// token, and the resolver reads the GUID out of the OBJECT (`0x515994`), not out of
// `[mgr+0xc0]` — so with no object it holds `0:0`. The roster fallback `0x491900` then
// bails on a zero GUID at `0x4e80aa je` *before* it fetches the active player, so the
// `0 == 0` that would otherwise answer "yes, that's me" is never reached: `0x516001
// lua_pushnil`. `UnitLevel 0x517fc0` likewise carries no `"player"` compare at all and
// reaches its total-miss arm `0x51813e push 0; push 0` — the **number 0**, not nil. The
// two misses are deliberately asymmetric and a client that modelled "no data" uniformly
// would be wrong on one of them (wow-re `ui/scratch/unit-verbs-before-player-object.md`
// §2-§3; it also corrected that repo's own `main`, which had published the opposite).
//
// This seat is no longer what makes the player *answerable* — the record above is — so it
// no longer has to claim a unit exists in order to deliver a name. What it still carries
// is the handful of fields the corpus reads at file scope that are neither the record's
// nor the descriptor's to say yet, the faction side below chief among them.
exists: false,
name: Some(row.name.clone()),
level: u32::from(row.level),
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
sex: match row.gender {
0 => 2,
1 => 3,
_ => 0,
},
sex,
is_player: true,
player_controlled: true,
// Nil here is not "no faction", it is a state a player character cannot be in, and
@@ -565,3 +565,44 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
s.errors()
);
}
/// **The CORNER arm must end up owned too — the other half of the existence gate** (decision 2259).
///
/// The reference's corner arm (`0x492a42`) writes no owner itself; the owner is restored purely by
/// the `+0x444` handler, `OnTooltipSetDefaultAnchor` → `GameTooltip_SetDefaultAnchor(this,
/// UIParent)` → `SetOwner(UIParent, "ANCHOR_NONE")`. And the owner really is 0 on the way in:
/// `Tooltip::Hide 0x530a60` *is* `SetOwner(NULL, 0, 0, 0)`, so every hover starts un-owned.
///
/// That makes this test the precondition for narrowing the placement fork at all. Moving an object
/// from the cursor arm to the corner arm is only safe while the corner arm produces an OWNED,
/// SHOWN plate — otherwise those objects would build their lines and then hide, which is precisely
/// the failure 2255 had just fixed on the cursor arm.
#[test]
fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
assert!(owned, "the corner plate is owned; errors: {:?}", s.errors());
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(shown, "and it is on screen; errors: {:?}", s.errors());
}
/// And it survives the same addon hook the cursor arm had to: `!Questie`'s `OnShow` handler ends in
/// `GameTooltip:Show()`, and `:Show()` is the existence gate `0x530a80`.
#[test]
fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() {
let mut s = harness(&[]);
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
.unwrap();
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(
shown,
"the corner plate is still up; errors: {:?}",
s.errors()
);
}
@@ -1657,3 +1657,68 @@ ScreenProbeHeight = GetScreenHeight()
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The entry load seeds the player record, not just the snapshot** (decisions 2261/2263).
///
/// [`super::seat_from_roster`]'s `"player"` push is the descriptor's stand-in and is *replaced*
/// the moment the real one streams in — which is how decision 2260's nameless push reached
/// `UnitName("player")`. The buffer is seeded beside it, from the same roster row, and the verb
/// reads only that; so the token can be replaced by a nameless snapshot or removed outright and
/// the name still answers, exactly as the reference's never-cleared `0xc27d88` does.
#[test]
fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _capture, _home) = hermetic_probe("nameseed");
let mut world = booted_world();
log_in_as(&mut world, "Nelprifour", 0x2A);
assert_eq!(
probe_saw(&world).as_deref(),
Some("Nelprifour"),
"addon file scope reads the live character, as it always has (1230)"
);
let mut script = world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM");
// The feed's 2260 push: the descriptor landed, the name cache missed for our own guid.
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
has_object: true,
name: None,
..Default::default()
}),
);
assert_eq!(
script
.eval::<Option<String>>(r#"return UnitName("player")"#)
.unwrap()
.as_deref(),
Some("Nelprifour"),
"the record answers, so a nameless snapshot is invisible to the verb"
);
assert_eq!(
script
.eval::<Option<String>>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap()
.as_deref(),
Some("WARRIOR"),
"…and the same for the other three fields the reference reads off that record (2263)"
);
// …and so does the logout despawn, which removes the token altogether.
script.set_unit("player", None);
assert_eq!(
script
.eval::<Option<String>>(r#"return UnitName("player")"#)
.unwrap()
.as_deref(),
Some("Nelprifour")
);
let _ = std::fs::remove_dir_all(&tmp);
}
+23 -15
View File
@@ -845,22 +845,30 @@ fn drive_mouseover_tooltip(
// distinction the director's two reference observations agree on — a **GENERIC(5)**
// signpost follows the cursor, an interactable GameObject sits in the corner.
//
// Not merely a guess-shaped proxy: after 0762 the only objects that are eligible for a
// tooltip *and* never highlightable are GENERIC ones, so "GENERIC" and "not interactable"
// pick out the same set here. They diverge only for the three always-eligible types
// (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25), which is exactly where a pin would
// settle it. Flagged INTERIM in 0766 rather than presented as verified.
// 0766 keyed this on "is it GENERIC(5)" and said plainly that it was a proxy: the real
// client asks the object's own `[vtbl+0x5c]`, and what selects it was not then pinned.
// **It is pinned now, and it is narrower** (decision 2259): `[vtbl+0x5c]` is `0x5f8630`,
// whose body is `template.data[0x621b00(type, semantic 0x13)] != 0`, and key `0x13`
// resolves for exactly ONE of the 31 GO types — GENERIC(5), at `data[0]`. Every other type
// gets `-1` back and `0x5f8150`'s unsigned bound turns that into FALSE.
//
// **THE PIN HAS ARRIVED, AND IT IS NARROWER THAN THIS LINE** (2255): `[vtbl+0x5c]` is
// `0x5f8630` = `template.data[0x621b00(type, 0x13)] != 0`, and semantic key `0x13` exists
// on exactly ONE of the 31 types — GENERIC(5), at `data[0]`. So the cursor arm is "GENERIC
// **with `data[0]` set**", and a GENERIC with it clear is corner-seated; the three
// always-eligible types above are corner-seated too, so 0766's "GENERIC" reading wins over
// its "not interactable" one. Applying it moves 190 of the 1387 hoverable type-5 templates
// from the cursor to the corner — a visible change, so it is its own slice and not a
// passenger on a bug fix (2255 carries the counts and the reasoning).
let cursor_seated =
stores.get(entity).map(|s| s.0.gameobject_type_id()) == Ok(GO_TYPE_GENERIC);
// So the cursor arm is **GENERIC with `data[0]` set**, and a GENERIC with it clear is
// corner-seated like everything else. That also settles 0766's named divergence: the three
// always-eligible types (SPELL_FOCUS 8 / DUEL_ARBITER 16 / FISHINGHOLE 25) carry no key
// `0x13`, so all three are corner-seated — 0766's "GENERIC" reading wins over its "not
// interactable" one.
//
// **`data[0]` is not `data[1]`.** The neighbouring slot is the mouseover-ELIGIBILITY column
// (0762, semantic `0x12`, `0x5f4830`), and the two answer different questions: `data[1]`
// says whether the object is hoverable at all, `data[0]` only says *where its plate sits*.
// 342 of the 447 type-5 entries in the reference's own `gameobjectcache.wdb` carry both;
// the objects that differ are hoverable and corner-seated, not silent.
let cursor_seated = stores.get(entity).map(|s| s.0.gameobject_type_id())
== Ok(GO_TYPE_GENERIC)
&& go_inputs
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px → the VM's y-up 768-virtual units (÷s, the input seam's own conversion) —
// the anchor this point seats is resolved in UI units, so a raw-px point lands the
// plate (s−1)× the cursor's distance from the bottom-left corner away from it.
+25
View File
@@ -267,6 +267,31 @@ impl super::UiScript {
///
/// Set at world entry from the realm the session actually connected to. Idempotent, and the
/// empty string is a legitimate value (no realm yet), not a "clear".
/// Seed the **local player record** — the reference's `0xc27d80`, copied from the char-enum
/// row at the character-select Enter World commit (decisions 2261/2263, and see
/// [`super::PlayerRecord`] for the bytes and the four verbs that read it).
///
/// Called from the world-entry UI load beside [`Self::set_realm_name`], and for the same
/// reason 1195 put the realm there: the values have to be in the VM before a single addon file
/// runs, because `local currentPlayer = UnitName("player")` at file scope is the corpus idiom.
/// The reference has them a whole login earlier still.
///
/// **A record with no name is refused, not stored.** The only unset state is "no Enter World
/// has been committed in this process"; once the record holds a character, nothing in the
/// reference's image ever empties it again, so a caller with nothing to say must leave the
/// last answer standing rather than blank it. The write is whole-record because the
/// reference's is one `rep movsd`: these four fields describe one character and can never
/// legitimately be seeded from two.
pub fn set_player_record(&mut self, record: super::PlayerRecord) {
if record.name.is_empty() {
return;
}
let mut model = self.model_mut();
if model.player_record != record {
model.player_record = record;
}
}
pub fn set_realm_name(&mut self, realm: &str) {
{
let mut model = self.model_mut();
+2 -1
View File
@@ -279,7 +279,8 @@ pub use types::{
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
grey_band, level_reads_unknown, power_token, unit_is_grey, PlayerRecord, SelectionRequest,
UnitState,
};
pub use weapon_enchant::WeaponEnchant;
pub use who_sort::{WhoSortChain, WhoSortKey};
+21
View File
@@ -1726,6 +1726,26 @@ pub(crate) struct Model {
/// payload, so two calls in a frame are two captures.
pub(crate) screenshot_asks: u32,
pub(crate) realm_name: String,
/// **The local player record — `0xc27d80`, ours** (decisions 2261 and 2263).
///
/// The bytes, the four verbs that read it and why `UnitLevel` is not among them are on
/// [`super::PlayerRecord`] itself; what belongs here is the *lifetime*, because that is what
/// this field's position in the `Model` decides.
///
/// **Two writers, and the asymmetry between them is the mechanism.** The world-entry UI load
/// seeds it from the roster row of the pick in flight — our copy of the same char-enum record
/// — beside [`Self::realm_name`] and before the addon walk (1195's slot, 1230's source); and
/// [`super::UiScript::set_unit`] keeps it in step for any `"player"` push that **carries** the
/// field. A push that does not carry it leaves it standing. So the record can be corrected but
/// never blanked, which is the reference's never-cleared property expressed where it can be
/// relied on — and decision 2260 is why that matters: a name-cache miss for our own guid
/// reached Lua as a nameless player snapshot and took KLHThreatMeter down with it.
///
/// Our VM is rebuilt per login (1290) where the reference's record simply persists, so each new
/// VM is told once. That is the whole of the difference, and it is unobservable: the four verbs
/// do not exist between logins either — they live only in the in-game table `0x850438`, which
/// `UI_Init` installs and the teardown nils, and `GlueScript_RegisterBindings 0x46abb0` skips.
pub(crate) player_record: super::PlayerRecord,
/// The hearthstone bind location's NAME, behind `GetBindLocation()` — the app resolves the
/// `SMSG_BINDPOINTUPDATE` area id through the same AreaTable catalog the hearthstone's `$z`
/// token already uses, and pushes the resolved string here.
@@ -2224,6 +2244,7 @@ impl Model {
played_time_asks: 0,
screenshot_asks: 0,
realm_name: String::new(),
player_record: super::PlayerRecord::default(),
bind_location: String::new(),
gm_ticket_categories: Vec::new(),
gm_ticket_intents: Vec::new(),
+84 -11
View File
@@ -21,9 +21,36 @@ use super::super::binding_abi::flag;
use super::super::Model;
use super::{
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
unit_predicate, unknownobject, with_unit, SelectionRequest,
unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest,
};
/// **The `"player"` fast path, shared by the four verbs that take it** (decision 2263).
///
/// `Some(x)` = this token is `"player"` and `x` is the record's answer; `None` = it is any other
/// token and the caller falls through to the unit resolver. That is the reference's own shape —
/// a full-string, case-insensitive compare of the token against `0x847894` (`b"player\0"`) whose
/// **match is the fall-through** — and it is one function rather than four copies for the reason
/// [`super::super::names::gated_rank`](crate)'s sibling comment gives about rank: four readers of
/// one record, each testing the token itself, is how they drift apart. `UnitName`, `UnitRace`,
/// `UnitClass` and `UnitSex` are the whole set; `UnitLevel` is **not** one of them.
///
/// The compare is a full string, not a prefix: `"playerfoo"` is a recognised-but-unresolvable
/// token ([`super::token_recognised`]) and goes to the resolver, not here.
fn player_record_arm<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&PlayerRecord) -> T,
) -> Option<T> {
if !token
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("player"))
{
return None;
}
let model = lua.app_data_ref::<Model>().expect("model app_data");
Some(f(&model.player_record))
}
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
/// the class byte `[[player+0x110]+0x79]` against. That byte is `UNIT_FIELD_BYTES_0` byte 1, the
/// same value [`PlayerReqState::class_id`](super::super::PlayerReqState) carries; `UnitClass`'s own
@@ -180,8 +207,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
//
// Value 1 — and the ONLY two nils a recognised token can produce (`0x517020`, wow-re
// `ui/scratch/binding-shape-arity-law.md` §2.1): the `"player"` fast path reads the
// local name buffer and pushes nil when it is empty (`0x517083` → `0x5abdc0`), and a
// token that resolves to GUID 0 pushes nil (`0x5170c0`). EVERY other path ends in a
// local name buffer and pushes nil when it is empty (`0x51708c` → `0x5abdc0`; the
// `0x517083` this used to cite is the token's string *compare*, `call 0x64a4c0` —
// corrected 2261), and a token that resolves to GUID 0 pushes nil (`0x5170c0`).
// The empty-buffer nil is not an explicit push either: `0x517095` is
// `lua_pushstring`, which falls through on NULL at `0x6f3895` into `lua_pushnil`. EVERY other path ends in a
// string — the cached name, or `FrameScript_GetText("UNKNOWNOBJECT")`: `0x517220` for
// a GUID with no object and no cache row, `0x609324` inside `CGUnit_C::GetUnitName`
// for a unit whose name cache has not answered or is stale (a pet's is
@@ -195,17 +225,30 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// named gap), so a not-selectable unit reads its name there with `UnitExists` nil; the
// name resolver's own nil is GUID 0 and nothing else, and a feed that seats a token
// has resolved it. Decision 2002.
//
// **The `"player"` arm is the BUFFER, not the snapshot** (decision 2261). `0x51708c`
// reads `0x5abdc0` — the local name buffer at `0xc27d88` — and returns; it never
// reaches the resolver, so no object, no descriptor and no name-query answer is
// involved on this path at all. Modelling it off the snapshot (as this did until
// 2261) put the one name the client always knows behind the one cache that can miss:
// decision 2260's realm-cache load dropped our own guid, the feed pushed a nameless
// player over the roster seat, and `UnitName("player")` read nil in the world.
//
// The buffer's emptiness is still the reference's own nil — it just means something
// a snapshot cannot say, and something a client that is in the world never is:
// "no Enter World has been committed in this process".
if let Some(seeded) = player_record_arm(lua, &token, |r| r.name.clone()) {
let name = if seeded.is_empty() {
Value::Nil
} else {
Value::String(lua.create_string(&seeded)?)
};
return Ok((name, Value::Nil));
}
let name = with_unit(lua, &token, None, |u| Some(u.name.clone()))?;
let name = match name {
None => Value::Nil,
Some(Some(n)) => Value::String(lua.create_string(&n)?),
Some(None)
if token
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("player")) =>
{
Value::Nil
}
Some(None) => Value::String(unknownobject(lua)?),
};
Ok((name, Value::Nil))
@@ -862,6 +905,18 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitRace("unit")"#,
)?);
// The `"player"` arm is the RECORD, not the snapshot (decision 2263): `0x518269`
// reads `0x5abdd0` — `0xc27e80`, the char-enum row's race byte — and resolves it
// through `ChrRaces`, never reaching the unit resolver. See [`PlayerRecord`].
if let Some(pair) = player_record_arm(lua, &token, |r| r.race.clone()) {
return match pair {
Some((loc, file)) => Ok((
Value::String(lua.create_string(&loc)?),
Value::String(lua.create_string(&file)?),
)),
None => Ok((Value::Nil, Value::Nil)),
};
}
let pair = with_unit(lua, &token, None, |u| {
u.race.clone().zip(u.race_file.clone())
})?;
@@ -882,6 +937,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitClass("unit")"#,
)?);
// The `"player"` arm is the RECORD (decision 2263): `0x5183b9` → `0x5abde0`,
// `0xc27e81` through `ChrClasses`. Second return is the UPPERCASE token.
if let Some(pair) = player_record_arm(lua, &token, |r| r.class.clone()) {
return match pair {
Some((loc, file)) => Ok((
Value::String(lua.create_string(&loc)?),
Value::String(lua.create_string(&file)?),
)),
None => Ok((Value::Nil, Value::Nil)),
};
}
let pair = with_unit(lua, &token, None, |u| {
u.class.clone().zip(u.class_file.clone())
})?;
@@ -945,7 +1011,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitSex("unit")"#,
)?);
let sex = with_unit(lua, &token, 0u8, |u| u.sex)?;
// The `"player"` arm is the RECORD (decision 2263): `0x517ef9` → `0x5abdf0`,
// `0xc27e82`. **No bounds check anywhere on that path** — the byte indexes
// `fild [4*eax+0x808be4]` over `{2,3,1,6}` directly — so an unset record answers `2`
// ("male") in the reference too, which is what our `0` maps to below.
let sex = match player_record_arm(lua, &token, |r| r.sex) {
Some(sex) => sex,
None => with_unit(lua, &token, 0u8, |u| u.sex)?,
};
Ok(Value::Integer(i64::from(if sex == 0 { 2 } else { sex })))
})?,
)?;
+80
View File
@@ -53,6 +53,59 @@ pub enum SelectionRequest {
LastEnemy,
}
/// **The local player record — `0xc27d80`, ours** (decision 2263).
///
/// A copy of ONE `SMSG_CHAR_ENUM` `CHARACTER_INFO` row, and the source the reference answers four
/// `"player"` verbs from. Each opens with the identical full-string, case-insensitive compare of
/// the token against `0x847894` (`b"player\0"`) whose **match is the fall-through**, and each is
/// unconditional — there is no arm that re-routes `"player"` to the unit resolver, so the
/// descriptor never supersedes this record even once the object exists:
///
/// | verb | fast path | accessor | byte |
/// |---|---|---|---|
/// | `UnitName` `0x517020` | `0x51708c` | `0x5abdc0` | `+0x08` name |
/// | `UnitRace` `0x518200` | `0x518269` | `0x5abdd0` | `+0x100` → `ChrRaces` |
/// | `UnitClass` `0x518350` | `0x5183b9` | `0x5abde0` | `+0x101` → `ChrClasses` |
/// | `UnitSex` `0x517e90` | `0x517ef9` | `0x5abdf0` | `+0x102` gender |
///
/// One writer image-wide — `0x5abd9e` (`rep movsd`, 0x44 dwords) inside `CGlueMgr::EnterWorld`,
/// at the character-select commit — and **no instruction anywhere clears it**. It is filled before
/// FrameXML loads and before `CMSG_PLAYER_LOGIN` is sent, which is why the reference can run addon
/// `OnUpdate` for many frames with no local player object and still answer all four (wow-re
/// `ui/scratch/unitname-player-seed-window.md` §6).
///
/// **`UnitLevel` is deliberately NOT here.** The record carries the level at `+0x108` and the
/// client ships an accessor for it, `0x5abe00`, that **nothing calls** — so `UnitLevel("player")`
/// reads the descriptor and answers `0` in exactly the window the four answer in.
///
/// **Resolved strings, not the raw bytes**, because this crate has no DBC: the reference resolves
/// `+0x100`/`+0x101` through `ChrRaces`/`ChrClasses` at call time, and the app does that same
/// resolution once when it seeds the record (it is the identical lookup
/// `ui_unit::race_names`/`class_names` already does for the unit snapshot, so the two cannot
/// disagree about what a race byte means).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlayerRecord {
/// `+0x08` — the NUL-terminated name buffer at `0xc27d88`. Empty is the record's only unset
/// state, and the reference's only `UnitName("player")` nil: `lua_pushstring(NULL)` at
/// `0x517095` tail-jumps into `lua_pushnil`. Unreachable from Lua in the real client, because
/// the verb is not registered until `UI_Init` and only an Enter World commit gets there.
pub name: String,
/// `+0x100` resolved — `UnitRace`'s `(localized, file token)` pair, e.g.
/// `("Night Elf", "NightElf")`. `None` is the all-zero record: race 0 has no `ChrRaces` row,
/// and the reference's bound/NULL-row arms push `nil, nil` rather than reaching the resolver.
pub race: Option<(String, String)>,
/// `+0x101` resolved — `UnitClass`'s `(localized, UPPERCASE token)` pair, e.g.
/// `("Warrior", "WARRIOR")`. `None` for the all-zero record, same shape as [`Self::race`].
pub class: Option<(String, String)>,
/// `+0x102` on `UnitSex`'s scale (2 male, 3 female) — **not** the wire's 0/1.
///
/// `0` is our unset marker and reads as `2`, which is not a fudge: `0x517ef9`'s accessor feeds
/// `fild [4*eax+0x808be4]` over `{2,3,1,6}` with **no bounds check at all**, so an all-zero
/// record answers `2` ("male") in the reference too. It is the one of the four with no
/// validity guard.
pub sex: u8,
}
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
/// draws between the app's net/ECS feed and the Lua API.
@@ -539,6 +592,33 @@ impl super::UiScript {
// canonical lowercase token, so a feed that ever pushed `"Target"` could not create
// a second, shadowing entry.
Some(s) => {
// **A `"player"` push keeps the record in step, field by field** (2261,
// widened by 2263). The reference writes `0xc27d80` once per Enter World
// commit and never clears it; our VM is rebuilt per login, so the entry load
// seeds it — and this keeps it true for any later push that KNOWS a field,
// without ever letting one that doesn't blank it.
//
// That asymmetry is the whole mechanism: 2260's nameless player snapshot is
// exactly the push that must not be able to reach these four verbs. It is also
// what keeps every fixture in the workspace correct without touching them — a
// test that seats a full player is, by this rule, also seating the record.
if token.eq_ignore_ascii_case("player") {
let rec = &mut model.player_record;
if let Some(name) = s.name.as_deref().filter(|n| !n.is_empty()) {
if rec.name != name {
rec.name = name.to_string();
}
}
if let Some(race) = s.race.clone().zip(s.race_file.clone()) {
rec.race = Some(race);
}
if let Some(class) = s.class.clone().zip(s.class_file.clone()) {
rec.class = Some(class);
}
if s.sex != 0 {
rec.sex = s.sex;
}
}
model.units_by_lower.insert(token.to_ascii_lowercase(), s);
}
None => {
+168 -1
View File
@@ -1,6 +1,6 @@
//! The `Unit*` binding tests (the parent module is the unit under test).
use crate::script::{PartyState, UiScript, UnitState};
use crate::script::{PartyState, PlayerRecord, UiScript, UnitState};
fn player() -> UnitState {
UnitState {
@@ -1995,3 +1995,170 @@ fn unit_on_taxi_is_one_or_nil_and_raises_the_reference_usage() {
"the gate's own message, not mlua's type error: {err}"
);
}
/// **The four `"player"` verbs read the seeded record, and nothing can blank it** (2261/2263).
///
/// The reference's `"player"` arm never reaches the resolver: `0x51708c` reads `0xc27d88`, a copy
/// of the char-enum row written once at the Enter World commit and never cleared anywhere in the
/// image. So the name survives everything that can happen to the *snapshot* — which is the whole
/// of decision 2260's bug: a name-cache miss for our own guid produced a nameless player push, it
/// replaced the roster seat, and the verb went nil in the world. That push is modelled here
/// exactly, and it must now be invisible to this verb.
#[test]
fn the_player_record_outlives_every_snapshot() {
let mut s = UiScript::new().unwrap();
// Before any Enter World commit — the buffer's one empty state, and the reference's nil.
assert!(s
.eval::<bool>(r#"return UnitName("player") == nil"#)
.unwrap());
// The world-entry load seeds it from the roster row, before a single addon file runs.
s.set_player_record(PlayerRecord {
name: "Nelprifour".into(),
race: Some(("Night Elf".into(), "NightElf".into())),
class: Some(("Priest".into(), "PRIEST".into())),
sex: 2,
});
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour",
"the buffer answers with no player snapshot at all — the reference runs addon OnUpdate \
for many frames with no local player object and this verb still answers"
);
// 2260's push, verbatim: the descriptor landed, the name cache missed for our own guid.
let nameless = UnitState {
exists: true,
has_object: true,
name: None,
level: 5,
..Default::default()
};
s.set_unit("player", Some(nameless));
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour",
"a nameless snapshot must not reach this verb — that push is the reported bug"
);
assert_eq!(
s.eval::<String>(r#"return UnitRace("player")"#).unwrap(),
"Night Elf",
"the same holds for the other three fields of the record (2263)"
);
assert_eq!(
s.eval::<String>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap(),
"PRIEST",
"UnitClass's second return is the UPPERCASE token"
);
assert_eq!(s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(), 2);
// …and the snapshot is still the snapshot for everything else. `UnitLevel` is the boundary:
// the record carries a level byte and the client's accessor for it (`0x5abe00`) is DEAD, so
// this one reads the descriptor even for `"player"`.
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 5);
// The token going away entirely (the logout despawn frames) does not empty the buffer either.
s.set_unit("player", None);
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour"
);
assert!(
s.eval::<bool>(r#"return UnitExists("player") == nil"#)
.unwrap(),
"the name and the unit are different questions — which is exactly what the buffer buys"
);
// An empty seed is refused, not stored: nothing in the reference ever empties this record.
s.set_player_record(PlayerRecord::default());
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour"
);
assert_eq!(
s.eval::<String>(r#"return UnitRace("player")"#).unwrap(),
"Night Elf"
);
// A NAMED push does keep it in step — a second login as somebody else (1290's per-login VM
// is the usual route, but a push must never leave the two disagreeing).
let mut other = player();
other.name = Some("Onewarrior".into());
s.set_unit("player", Some(other));
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Onewarrior"
);
assert_eq!(
s.eval::<String>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap(),
"WARRIOR",
"a push that carries the field updates it; one that does not leaves it standing"
);
assert_eq!(
s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(),
3,
"player() is female — the record follows the push"
);
// Still the family's two returns, the second nil (1840).
assert_eq!(
s.eval::<i64>(r#"local t = {UnitName("player")}; return table.getn(t)"#)
.unwrap(),
1
);
// …and the case fold on the token holds on the fast path too.
assert_eq!(
s.eval::<String>(r#"return UnitName("PLAYER")"#).unwrap(),
"Onewarrior"
);
}
/// **The unset record's four answers, which are not uniform** (decision 2263).
///
/// The state is "no Enter World has been committed in this process" — unreachable from Lua in the
/// reference, because the four verbs only exist inside `UI_Init`'s table and only an Enter World
/// commit gets there. It is reachable *here* (a bare VM, a capture, a test world with no pick), so
/// it is pinned to what the bytes do rather than left to whatever falls out:
///
/// - `UnitName` → nil, via `lua_pushstring(NULL)` at `0x517095` tail-jumping into `lua_pushnil`;
/// - `UnitRace`/`UnitClass` → `nil, nil`, via the DBC bound and NULL-row arms — race 0 and class 0
/// have no row, and those arms reach `pushnil` **without** falling back to the unit resolver;
/// - `UnitSex` → the number **2**, because `0x517ef9`'s accessor feeds `fild [4*eax+0x808be4]`
/// over `{2,3,1,6}` with **no bounds check at all**. It is the one of the four with no validity
/// guard, and "male" is what an all-zero record means to it.
#[test]
fn an_unseeded_player_record_answers_the_references_four_ways() {
let s = UiScript::new().unwrap();
assert!(s
.eval::<bool>(r#"return UnitName("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitRace("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local _, t = UnitRace("player"); return t == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitClass("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local _, t = UnitClass("player"); return t == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(),
2,
"the one of the four with no bounds check — an all-zero record reads male, not nil"
);
// And the fast path is the TOKEN, not a prefix: `"playerfoo"` is recognised but resolves to
// nothing, so it goes to the resolver and answers the resolver's nil.
assert!(s
.eval::<bool>(r#"return UnitName("playerfoo") == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return UnitSex("playerfoo")"#).unwrap(),
2,
"…which for UnitSex is the same 2, by the binding's own unresolved-token default"
);
}
+22 -10
View File
@@ -640,17 +640,29 @@ pub(crate) fn stream_chunk_clutter(
);
cc.built = built;
}
// Two cases, and only one is a defect — the message always said so, but BOTH were warnings, so
// the benign one cried wolf. A login/teleport burst has a BACKLOG: the per-frame cap is why the
// near chunks are late, and the whole burst runs behind the loading cover. The director's
// 2026-09-15 log has three of these at t+1.7 s under a cover that did not lift until t+4.3 s —
// including one announcing grass at 4.4 yd that nobody could possibly have seen. With NO
// backlog the cap is not the cause: the distance gate let a near chunk through late on an
// ordinary frame, the player can see that one, and that one still warns.
if late > 0 {
warn!(
"clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest {late_nearest:.1} yd) \
— grass appeared where it could already be seen; {}",
cfg.fade_far,
if backlog > 0 {
format!("{backlog} more still queued behind the {CLUTTER_BUILDS_PER_FRAME}/frame cap (expected in a login/teleport burst)")
} else {
"no build backlog, so the DISTANCE GATE let it through late".to_string()
}
);
if backlog > 0 {
debug!(
"clutter: {late} chunk(s) built inside the {:.0} yd horizon (nearest \
{late_nearest:.1} yd) — {backlog} more queued behind the \
{CLUTTER_BUILDS_PER_FRAME}/frame cap (the expected login/teleport burst)",
cfg.fade_far,
);
} else {
warn!(
"clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest \
{late_nearest:.1} yd) — grass appeared where it could already be seen, and with \
no build backlog, so the DISTANCE GATE let it through late",
cfg.fade_far,
);
}
}
}
+17 -7
View File
@@ -838,11 +838,22 @@ fn prepare_textures(
let Some(vp) = camera.physical_viewport_size else {
continue;
};
// A claimed view (some [`FfxBackdrop`] names it) has no combine of its own — the UI
// camera's ground pass is it, keyed on THAT camera's target — so it carries none rather
// than a pair keyed on a target nothing writes.
let combine_format = (!claims.0.contains(&entity))
.then(|| FinalPassTarget::format(&camera.output_mode, target));
// The view's OWN combine pair, specialised **whether or not this view is claimed**
// (decision 2262). A claimed view (some [`FfxBackdrop`] names it) has no combine of its
// own — the UI camera's ground pass is it, keyed on THAT camera's target — so it carries
// none rather than a pair keyed on a target nothing writes. But the claim is not a
// property of the world, it is a property of *this frame*: it drops the moment the UI
// camera loses its `ViewTarget`, which is what `prepare_view_targets` does as soon as the
// window's surface goes away. At app exit that is harmless (bevy runs one to three more
// updates after the last presented frame — see `benilla_app::shutdown`) and it is exactly
// what the director's 2026-09-15 log caught: two `pipeline compiled LIVE` lines in the
// same millisecond as "No windows are open, exiting". A minimize to zero size or a surface
// reconfigure reaches the same branch **while the player is looking at the frame**, and
// there the pair would be two synchronous Metal compiles on the render thread. Specialising
// is a cached lookup on a four-field key, so holding the pair warm from the first covered
// frame costs that lookup and nothing else.
let own = specializer.pair(FinalPassTarget::format(&camera.output_mode, target));
let combine = (!claims.0.contains(&entity)).then_some(own);
// The reference's RT-dim chain: ½ and ¼, floored (clamp ≥8 — `ffx_compute_rt_dims`).
let mut tex = |label: &'static str, w: u32, h: u32| {
texture_cache.get(
@@ -871,11 +882,10 @@ fn prepare_textures(
if existing.is_some_and(|t| {
t.quarter_a.texture.id() == quarter_a.texture.id()
&& t.quarter_b.texture.id() == quarter_b.texture.id()
&& t.combine.as_ref().map(|c| c.format) == combine_format
&& t.combine.as_ref().map(|c| c.format) == combine.as_ref().map(|c| c.format)
}) {
continue;
}
let combine = combine_format.map(|format| specializer.pair(format));
let pipelines = &specializer.pipelines;
let layout_filter = specializer
.pipeline_cache
+23 -6
View File
@@ -146,6 +146,10 @@ pub struct RigPalettes {
/// Allocations refused since the last census line (`WOW_RIG_CENSUS`) — the exhaustion
/// diagnosis's live denial rate (decision 0863). Read-and-reset by [`census_rig_palettes`].
denied: u32,
/// Has the exhaustion warn already been logged this session? A latch, not a counter, and
/// deliberately never reset: [`Self::denied`] is `mem::take`n by the census, so gating the
/// warn on it would re-fire once per census window instead of once per session (2264).
denied_warned: bool,
/// `WOW_RIG_COST` meters (decision 0736 premise check): whole-vec deep copies this frame
/// (`Arc::make_mut` clones when the extract still holds last publish's reference), the µs
/// they took, and the rows (bones) written. Printed + reset by [`publish_rig_palettes`].
@@ -174,6 +178,7 @@ impl Default for RigPalettes {
peak_bones: 0,
live_bones: 0,
denied: 0,
denied_warned: false,
cost_copies: 0,
cost_copy_us: 0.0,
cost_rows: 0,
@@ -697,7 +702,8 @@ impl RigSkin {
/// Allocate a palette rig over live joint entities (the effect/booth/equipment lane — the
/// change sweep computes its rows; doodads moved to [`Self::allocate_bones`], decision 1365).
/// `None` (with one loud warn per session) when the table is full — the caller renders the
/// `None` (with one loud warn per session — a promise this doc made for a long time before
/// the code kept it, 2264) when the table is full — the caller renders the
/// static bind-pose mesh instead.
pub fn allocate(
palettes: &mut RigPalettes,
@@ -724,11 +730,22 @@ impl RigSkin {
}),
None => {
palettes.denied += 1;
let (s, b, ps, pb) = palettes.occupancy();
warn!(
"rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \
peak {ps}/{pb}) — rig renders at bind pose"
);
// **Once per session, as this function's doc has always promised.** It did not
// keep that promise: the denial path warned on every call, and the lazy-doodad
// caller retries *every frame while the host is drawn*
// (`doodad_anim::lazy` — "table full — the warn fired; the gate retries while
// drawn"), so a crowded scene turned one fact into a per-frame flood that buries
// every other warning in the log. The repeats were never the signal anyway:
// `denied` is already counted here and already reported by the palette census.
if !palettes.denied_warned {
palettes.denied_warned = true;
let (s, b, ps, pb) = palettes.occupancy();
warn!(
"rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \
peak {ps}/{pb}) — rig renders at bind pose. Further denials are counted \
in `denied`, not logged; run with WOW_RIG_CENSUS for the running tally."
);
}
None
}
}