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