Quiver loads, teleports stop losing the city, the camera eases right

- Quiver works end to end (the reported nil-field error): 1.12's widget method tables are flat, so the 19 Region methods are ONE function per name — a frame's `GetHeight` IS the texture's — where we had two implementations behind two disjoint registries; `Button:SetFontString` and the Set/GetMin/MaxResize quad land with it, and the 219-addon survey moves loads-without-error 150 → 151 with every other addon byte-identical
- a teleport stops dropping you into a bare city: the reveal gate now waits on geometry that can actually draw (14 regions had sat undrawn for 630 ms with the cover already down), a relocating snap ≥100 yd raises the cover, and the pipeline warm pass runs once per process — a far same-map teleport's cover drops 3.8 s → 0.8 s
- the empty-world flash on a fully-resident teleport dies with it: the camera's world pose publishes before anything in Update reads it, so the visibility verdict stops being computed up to 644 yd from the eye — the lag reads 0 yd on every snap frame of every arm
- the camera follow becomes the engine's own curve — an edge-armed cosine smoothstep clamped to 0.1–2 s behind a 7-state input classifier, the enum 0/1/2 with Smart staying the default — and overlapping chat bubbles stack as whole cards, one frame level per bubble, nearest to the camera on top

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:10:46 +07:00
co-authored by Claude Fable 5
parent 532240f6e7
commit ad2a1fb2d1
36 changed files with 2375 additions and 232 deletions
+23 -10
View File
@@ -852,7 +852,12 @@
local v = OptionsRow_Get(row)
local match
for _, e in ipairs(row.entries) do
if e.value == v then match = e end
-- `alt` is a second value the same entry ANSWERS to (1502): 1.12's own Camera
-- Following Style dropdown writes "3" for Never where the engine's tables are
-- indexed 0/1/2, so a config or addon carrying that value means Never — and the
-- nearest-stop fallback below, which assumes the values are ordinal, would show
-- "Always" for it. A categorical dropdown says which strays are its own.
if e.value == v or e.alt == v then match = e end
end
if not match and tonumber(v) then
local best
@@ -1989,14 +1994,22 @@
"22" means the wheel stops at 22 yards, which is the
thing being chosen. Our default is the factor fully
raised (30 yd), where 1.12 ships 1.0. -->
<!-- Camera Following Style (decision 1493): 1.12's own
dropdown (UIOptionsFrameCameraDropDown), values and
order and all — Smart "1", Always "2", Never "3", with
each entry carrying the reference's own description key
so the row's plate changes with the selection, the way
that dropdown's does. Seated at the head of the camera
block because it decides whether the camera follows at
all; the distance slider under it decides how far. -->
<!-- Camera Following Style (1493, values re-pinned by
1502): 1.12's own dropdown
(UIOptionsFrameCameraDropDown) in its own order —
Smart, Always, Never — each entry carrying the
reference's own description key (keyed by POSITION,
OPTION_TOOLTIP_CAMERA1..3) so the row's plate changes
with the selection, the way that dropdown's does.
The VALUES are the ENGINE's, not that dropdown's: the
reference writes 1/2/3 and 3 is not a style at all —
its validator accepts it while the terrain-tilt
consumer indexes 360 bytes off the end of its table.
0/1/2 is what the client's own tables are indexed by
(wow-re camera-smooth-style.md §2/§4). Seated at the
head of the camera block because it decides whether
the camera follows at all; the distance slider under
it decides how far. -->
<Frame name="$parentRowCameraFollowStyle" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
@@ -2008,7 +2021,7 @@
OptionsDropdownRow_Setup(self, {
{ text = "Smart", value = "1", tip = "OPTION_TOOLTIP_CAMERA1" },
{ text = "Always", value = "2", tip = "OPTION_TOOLTIP_CAMERA2" },
{ text = "Never", value = "3", tip = "OPTION_TOOLTIP_CAMERA3" },
{ text = "Never", value = "0", alt = "3", tip = "OPTION_TOOLTIP_CAMERA3" },
})
</OnLoad></Scripts>
</Frame>
+1 -1
View File
@@ -117,7 +117,7 @@ pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock,
ProbeExitPlugin, ProbeFocusPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
SchedCensusPlugin, UnitVisualsPlugin,
RevealAuditPlugin, SchedCensusPlugin, UnitVisualsPlugin,
};
use scenarios::GlueScreen;
use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS};
@@ -47,6 +47,11 @@ pub(crate) use visual_census::UnitVisualsPlugin;
mod dress_census;
pub(crate) use dress_census::DressCensusPlugin;
/// The reveal audit — per-frame, from a snap: every term that decides whether the world about
/// to be shown is actually drawable (decision 1498).
mod reveal;
pub(crate) use reveal::RevealAuditPlugin;
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
/// census — "what is resident right now, and what is it made of".
mod world_census;
@@ -0,0 +1,137 @@
//! The **reveal audit** (`WOW_REVEAL=<frames>`) — one line per frame from a snap, naming every
//! term that decides whether the world the player is about to be shown is actually there.
//!
//! "Teleported and the city wasn't drawn for a frame or more" is a *temporal* claim about the
//! frames either side of a reveal, and neither a screenshot nor the loading screen's own 3-second
//! wait line can hold one: the screenshot is one frame with no state attached, and the wait line
//! only speaks for loads slow enough to be stuck. This prints the whole window — the cover, the
//! residency terms behind [`WorldLoadProgress::presentable`], the settle hold, and the retained
//! pass's collected-vs-published region counts — so "which term let the reveal through" is read
//! off a column instead of guessed.
//!
//! ```text
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_CHAT=".go xyz -9250.45 160.86 67.90 0;.tele stormwind" \
//! WOW_PROBE_CHAT_AT=20 WOW_PROBE_CHAT_EVERY=25 WOW_REVEAL=120 \
//! WOW_PROBE_EXIT_AT=70 cargo run -q -p benilla | grep REVEAL
//! ```
//!
//! The arming edge is the snap itself (a same-map teleport or a worldport), so the window covers
//! the frames a cover would have to be up for — and, when none is, the frames the player is
//! looking at the destination through nothing at all.
//!
//! **It runs in `Last`, and that is load-bearing.** Half of what decides whether a frame has a
//! world in it is settled in `PostUpdate` — bevy's visibility check, the exterior-scene gate, the
//! retained pass's own scene walk — and a line printed in `Update` reports those columns from the
//! *previous* frame. Against a one-or-two-frame artefact an off-by-one instrument is worse than
//! none: it attributes the hole to the frame beside it. In `Last` every column below is this
//! frame's, residency and draw alike.
//!
//! The columns split into two halves on purpose. **Residency** (`res`, `focus`, `scene`, `place`,
//! `coll`, `merge`, `gx`) is *what the world has* — the terms behind
//! [`WorldLoadProgress::presentable`], the ones the cover is raised and cleared on. **Draw**
//! (`drawn`, `hid`, `sel`, `room`, `win`, `pvseye`) is *what the frame put on screen*. A reveal
//! defect where every residency term reads ready and the draw half has collapsed is not a
//! streaming bug at all — it is the visibility authority answering about somewhere else.
use bevy::prelude::*;
use super::ProbeClock;
use benilla_world::static_gx::StaticGx;
use benilla_world::terrain_stream::WorldLoadProgress;
use benilla_world::world_census::WorldCensus;
/// The audit's window: how many frames after a snap to report, from `WOW_REVEAL`.
#[derive(Resource)]
struct RevealAudit {
frames: u32,
/// Frames printed since the current arm; `None` = not armed.
n: Option<u32>,
/// [`ProbeClock`] seconds at the arming snap — the `t=` column. The wall clock, like every
/// other probe schedule (decision 0789): a reveal window is measured in real milliseconds,
/// and the virtual clock clamps exactly the hitching frames this instrument exists to see.
since: f32,
}
pub(crate) struct RevealAuditPlugin;
impl Plugin for RevealAuditPlugin {
fn build(&self, app: &mut App) {
let frames = std::env::var("WOW_REVEAL")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(120u32);
app.insert_resource(RevealAudit {
frames,
n: None,
since: 0.0,
})
// In `Last`: after the streamer published residency, after the screen decided what to do
// with it, and after `PostUpdate` settled what actually draws (see the module docs).
.add_systems(Last, drive_reveal_audit);
}
}
#[allow(clippy::too_many_arguments)]
fn drive_reveal_audit(
mut audit: ResMut<RevealAudit>,
progress: Res<WorldLoadProgress>,
gx: Option<Res<StaticGx>>,
screen: Res<crate::loading_screen::LoadingScreen>,
player: Option<Res<crate::player::Player>>,
census: WorldCensus,
cam: Query<&GlobalTransform, With<benilla_world::view::WorldCamera>>,
time: ProbeClock,
mut teleports: MessageReader<crate::net::TeleportMessage>,
mut worldports: MessageReader<crate::net::WorldportMessage>,
) {
let now = time.elapsed_secs();
if teleports.read().next().is_some() || worldports.read().next().is_some() {
audit.n = Some(0);
audit.since = now;
}
let Some(n) = audit.n else {
return;
};
if n >= audit.frames {
audit.n = None;
return;
}
audit.n = Some(n + 1);
let (collected, published, selected) = gx
.as_deref()
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::wmo_census);
let (cells_drawn, wmo_drawn, groups_drawn) = gx
.as_deref()
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::draw_census);
let seen = census.take();
// How far the visibility authority's pose is from the eye this frame is actually drawn from
// — ~0 in steady play, the whole jump on a snap frame it has not caught up with.
let pvs_lag = match (seen.pvs_eye, cam.iter().next()) {
(Some(eye), Some(now_eye)) => eye.distance(now_eye.translation()),
_ => f32::NAN,
};
info!(
"REVEAL n={n} t={:.0}ms cover={} res={}/{} focus={} scene={} place={} coll={} merge={} \
gx={} wmo={collected}/{published}/{selected} settling={} | drawn={}/{} hid={}/{} \
sel={cells_drawn}/{wmo_drawn}/{groups_drawn} room={} win={} pvslag={pvs_lag:.0}",
(now - audit.since) * 1000.0,
u8::from(screen.covering()),
progress.ready,
progress.total,
u8::from(progress.focus_resident),
u8::from(progress.scene_ready),
progress.placements_pending,
progress.colliders_pending,
progress.merge_pending,
progress.gx_pending,
u8::from(player.is_some_and(|p| p.settling)),
seen.drawn,
seen.submeshes,
seen.hidden,
seen.tagged,
seen.room.as_deref().unwrap_or("-"),
seen.windows.as_deref().unwrap_or("-"),
);
}
+147 -19
View File
@@ -32,6 +32,14 @@
//! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at
//! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line**
//! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406).
//! - **Stacking** (`0x4b1060`, the per-frame pass over ALL live bubbles — one caller, `0x4817a3`):
//! each bubble is its own frame and its frame LEVEL is restamped every frame from the CAMERA's
//! distance to the speaker. The list re-sorts farthest-first (`0x4b1360`, comparing
//! `[bubble+0x350]` = |cam − unit|², written at `0x4b12c0`), then a head→tail walk stamps
//! `SetFrameLevel(2), (3), (4)…` (`0x4b1309`). Level outranks draw layer in the client's total
//! order, so overlapping bubbles stack as WHOLE cards with the nearest speaker's on top —
//! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's
//! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.)
//!
//! Named divergences (all deliberate, decisions 0598/0599):
//! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p`
@@ -71,7 +79,7 @@ use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::StandBoxHeight;
use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates};
use benilla_assets::{AssetSet, WorldAssets};
@@ -121,15 +129,25 @@ const WRAP_W: f32 = 0.2;
/// (`G44·16.0/(S·1024.0)` — G44/S nets the width in the diagonal gx basis).
const BORDER_FRAC: f32 = 16.0 / 1024.0;
/// Paint order inside the overlays lane: the whole bubble sits UNDER the V-plates' 4..8 band
/// (same-unit overlap can't happen — the mutual exclusion — so this only orders cross-unit
/// stacking). The tail draws over the frame's bottom edge piece: its art carries the border
/// lines that make the seam read continuous.
/// Paint order **inside one bubble's frame level** — the four pieces, back→front. The tail draws
/// over the frame's bottom edge piece: its art carries the border lines that make the seam read
/// continuous, and the reference gets the same result from its own layer key (the tail is an
/// ARTWORK texture, the text an ARTWORK font string, and a batch drains every texture before
/// every font string — wow-re `ui/scratch/draw-order-law.md` §4).
const Z_BG: u64 = 0;
const Z_EDGE: u64 = 1;
const Z_TAIL: u64 = 2;
const Z_TEXT: u64 = 3;
/// The z base for the bubble at `rank` in this frame's farthest→nearest order — the port of the
/// reference's per-frame `SetFrameLevel(2 + i)` walk (`0x4b12d5`–`0x4b1312`, decision 1504). One
/// LEVEL per bubble, [`overlay_z::BUBBLE_STRIDE`] keys wide, so a whole bubble stacks over a whole
/// bubble; the whole band stays under the V-plates ([`crate::ui_pass::overlay_z`]).
fn level_z(rank: usize) -> u64 {
let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL);
overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE
}
/// Which CVar gates this chat kind's bubble — `None` = the kind never bubbles in v1.
/// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`).
/// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an
@@ -455,6 +473,7 @@ fn drive_bubbles(
let scale = window.single().map_or(1.0, Window::scale_factor);
let trace = std::env::var("WOW_BUBBLE_TRACE").as_deref() == Ok("1");
let mut dead = Vec::new();
let mut pending: Vec<Pending> = Vec::new();
for (guid, b) in bubbles.0.iter_mut() {
let Some((entity, _, tf)) = find(*guid) else {
dead.push(*guid); // the speaker despawned — the unit teardown takes its bubble
@@ -485,9 +504,9 @@ fn drive_bubbles(
if b.alpha <= 0.0 {
continue;
}
let (Some((cam, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art)
else {
continue; // no camera/atlas/art — lifetimes still tick, nothing draws
// No camera/atlas/art — lifetimes still tick, nothing draws.
let (Some((cam, cam_pose)), true) = (cam, atlas.is_some() && art.is_some()) else {
continue;
};
// World anchor (`0x4b0c30`): the unit's position, Z lifted by the LATCHED Stand-box height
// + 0.7 yd — seated BOTTOM at the projected point, growing upward. Every term is this
@@ -509,24 +528,80 @@ fn drive_bubbles(
refuse("no-viewport", *guid);
continue;
};
// Seated and projected here, DRAWN below: the level a bubble draws at is a property of
// the whole live set, not of this bubble ([`level_z`]), so the draw waits for the sort.
pending.push(Pending {
guid: *guid,
entity,
seat,
viewport,
// The sort key (`0x4b1251`–`0x4b12c0`): |camera − speaker|², the CAMERA's distance,
// not the local player's (which is the eligibility gate's, above).
cam_dist_sq: cam_pose.translation.distance_squared(tf.translation),
anchor: seat_world,
unit_pos: tf.translation,
});
}
for g in dead {
bubbles.0.remove(&g);
}
// ── The frame-level pass (`0x4b12d5`–`0x4b1312`) ─────────────────────────────────────────
// The reference re-sorts its live-bubble list by camera distance, FARTHEST at the head, and
// then walks head→tail stamping `SetFrameLevel(2), (3), (4)…` — so every bubble owns a frame
// level of its own and the NEAREST speaker's bubble ends up on top. (It only re-sorts when
// the camera moved or a bubble is dirty; the stamping walk runs unconditionally. Sorting
// every frame is the same list with no state to keep.) Ties break on guid so an exact
// distance tie can't flip the stack frame to frame.
stack_sort(&mut pending);
let (Some((_, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art) else {
return; // nothing reached `pending` either — the same gate refused it in the tick
};
for (rank, p) in pending.iter().enumerate() {
let Some(b) = bubbles.0.get(&p.guid) else {
continue; // despawned between the tick and the draw
};
draw_bubble(
atlas,
&mut quads,
art,
b,
seat,
viewport,
p.seat,
p.viewport,
scale,
trace,
entity,
seat_world,
tf.translation,
p.entity,
p.anchor,
p.unit_pos,
cam_pose,
level_z(rank),
);
}
for g in dead {
bubbles.0.remove(&g);
}
}
/// The stack order: **farthest camera distance first**, so the enumeration index that follows is
/// the reference's frame level and the nearest speaker's bubble draws last (on top). Ties break on
/// guid — the reference's list keeps its previous order across an exact tie because it re-inserts
/// only ahead of a *strictly* closer node, and a `HashMap` walk has no such memory.
fn stack_sort(pending: &mut [Pending]) {
pending.sort_by(|a, b| {
b.cam_dist_sq
.total_cmp(&a.cam_dist_sq)
.then(a.guid.cmp(&b.guid))
});
}
/// One bubble that passed the tick and projected on screen, waiting for the frame-level sort.
struct Pending {
guid: u64,
entity: Entity,
seat: Vec2,
viewport: Vec2,
/// |camera − speaker|², the reference's own sort key (`[bubble+0x350]`).
cam_dist_sq: f32,
/// The world seat and the speaker's position — the `bub` jitter trace's decomposition.
anchor: Vec3,
unit_pos: Vec3,
}
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
@@ -546,6 +621,8 @@ fn draw_bubble(
anchor: Vec3,
unit_pos: Vec3,
cam_pose: &Transform,
// This bubble's frame level, flattened to a z base ([`level_z`]).
z: u64,
) {
let basis = plate_basis(viewport);
let border = border_px(viewport, basis);
@@ -628,7 +705,7 @@ fn draw_bubble(
};
quads.overlays.push(UiQuad {
rect,
z_key: if p.is_bg { Z_BG } else { Z_EDGE },
z_key: z + if p.is_bg { Z_BG } else { Z_EDGE },
texture: Some(if p.is_bg {
art.bg.clone()
} else {
@@ -645,7 +722,7 @@ fn draw_bubble(
let cx = (frame.min.x + frame.max.x) * 0.5;
quads.overlays.push(UiQuad {
rect: Rect::new(cx - border, tail_top, cx, tail_top + border),
z_key: Z_TAIL,
z_key: z + Z_TAIL,
texture: Some(art.tail.clone()),
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, alpha],
@@ -663,7 +740,7 @@ fn draw_bubble(
h: JustifyH::Center,
v: JustifyV::Middle,
},
Z_TEXT,
z + Z_TEXT,
spec,
);
drop(e);
@@ -893,6 +970,57 @@ mod tests {
);
}
/// Every bubble owns a frame LEVEL: its four pieces are one contiguous band, bands ascend
/// with rank, and no two bubbles can interleave. This is the whole 1504 fix — with a flat
/// per-piece key, every bubble's background painted under every bubble's text, so a speaker's
/// line bled across a neighbour's card.
#[test]
fn each_bubble_gets_its_own_level_band() {
// The four pieces of one bubble, in paint order, all inside one level.
const { assert!(Z_BG < Z_EDGE && Z_EDGE < Z_TAIL && Z_TAIL < Z_TEXT) };
const { assert!(Z_TEXT < overlay_z::BUBBLE_STRIDE) };
for rank in 0..8usize {
// ...and every piece strictly below the NEXT bubble's background.
assert!(level_z(rank) + Z_TEXT < level_z(rank + 1) + Z_BG);
}
assert!(level_z(1) > level_z(0), "later rank draws later");
}
/// The band is bounded: however many bubbles are live, none of them can reach the V-plates'
/// band above ([`crate::ui_pass::overlay_z`]) or the floating combat text's below.
#[test]
fn the_bubble_band_stays_between_its_neighbours() {
for rank in [0, 1, 1_000, usize::MAX] {
let z = level_z(rank);
assert!(z >= overlay_z::BUBBLE, "over the floating numbers");
assert!(z + Z_TEXT < overlay_z::VPLATE, "under the V-plates");
}
}
/// The stack order is the reference's: farthest camera distance first (so the nearest
/// speaker's bubble ends up on top), guid breaking an exact tie deterministically.
#[test]
fn the_stack_is_sorted_farthest_first() {
let at = |guid: u64, d2: f32| Pending {
guid,
entity: Entity::PLACEHOLDER,
seat: Vec2::ZERO,
viewport: Vec2::ONE,
cam_dist_sq: d2,
anchor: Vec3::ZERO,
unit_pos: Vec3::ZERO,
};
let mut p = vec![at(7, 25.0), at(3, 400.0), at(9, 1.0), at(2, 400.0)];
stack_sort(&mut p);
assert_eq!(
p.iter().map(|q| q.guid).collect::<Vec<_>>(),
vec![2, 3, 7, 9],
"farthest first; the 400-yd² pair ordered by guid"
);
// The rank the draw uses IS the frame level: the nearest speaker draws last.
assert!(level_z(0) < level_z(p.len() - 1));
}
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
/// nets width/64), damped past the plate knee like every other size.
#[test]
+4 -3
View File
@@ -113,7 +113,7 @@ use bevy::prelude::*;
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::ui_pass::{UiQuadAppend, UiQuads};
use crate::ui_pass::{overlay_z, UiQuadAppend, UiQuads};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
use benilla_world::view::WorldCamera;
@@ -158,8 +158,9 @@ pub(crate) struct WorldTexts(Vec<WorldText>);
const MAX_PER_UNIT: usize = 4;
/// World text draws beneath every scripted UI quad (the real client renders it in the world scene,
/// under all UI): z 0 sorts first against the frame quads' packed strata keys.
const Z_WORLD_TEXT: u64 = 0;
/// under all UI) and beneath the other two append-lane systems: the append lane's bottom band
/// ([`crate::ui_pass::overlay_z`]) sorts first against the frame quads' packed strata keys.
const Z_WORLD_TEXT: u64 = overlay_z::WORLD_TEXT;
/// Admit a spawn against the per-unit cap: the client scans the unit's 4 slots for the first
/// NULL and **returns** when all are occupied — a hard drop, no eviction, no queue (`0x6c73f0`).
+68 -28
View File
@@ -31,7 +31,9 @@ use bevy::prelude::*;
use crate::chat_bubble::BubbleConfig;
use crate::minimap::MinimapZoom;
use crate::nameplates::NameConfig;
use crate::player::camera::{FollowStyle, LookConfig, ZoomLimit, MOUSE_SPEED_RANGE};
use crate::player::camera::{
FollowConfig, FollowStyle, LookConfig, ZoomLimit, FOLLOW_SPEED_RANGE, MOUSE_SPEED_RANGE,
};
use crate::portrait::PaneRate;
use crate::sound::SoundConfig;
use crate::target::ClickConfig;
@@ -107,13 +109,22 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// raised — because that IS benilla's shipped 30 yd ceiling, a knowing divergence from the
// reference's registrar "1" that camera.rs has carried in prose since it was written.
("cameraDistanceMaxFactor", "2"),
// Camera Following Style (1493): 1.12's `cameraSmoothStyle` — the auto-follow that swings the
// camera back behind the character. Registered "1" = Smart, which is BOTH the reference's
// registrar default and the director's call; benilla behaved as "3" (Never) unconditionally
// until this row, so the knob is what un-freezes a camera that was frozen by design. The three
// values are the reference dropdown's own (UIOptionsFrameCameraDropDown: 1 Smart, 2 Always,
// 3 Never) — see `FollowStyle`.
// Camera Following Style (1493, re-pinned by 1502): 1.12's `cameraSmoothStyle` — the
// auto-return that swings the camera back behind the character. Registered "1" = Smart, which
// is BOTH the reference's registrar default (byte-verified: the argument is loaded from
// `[0x84f4f4]` -> "1" at the `0x50ba92` register site) and the director's call; benilla behaved
// as Never unconditionally until this row. The enum is the ENGINE's — 0 Never, 1 Smart,
// 2 Always — NOT the 1/2/3 the reference's own dropdown writes; see `FollowStyle`.
("cameraSmoothStyle", "1"),
// Its sibling selector (1502), also registered "1": the reference reads THIS style instead
// whenever the state mask contains Track or Fear — the externally-driven states — indexing the
// same matrices. No row on any 1.12 panel, here or there; the reader is the host.
("cameraSmoothTrackingStyle", "1"),
// The auto-follow's rate (1502), °/s — 1.12's own AUTO_FOLLOW_SPEED slider
// (`UIOptionsFrameSliders`, 90..270 by 10), registered at the binary's "180.0" (`[0xbe1070]`).
// It sets the transition's DURATION (`|dyaw| / rate * factor`), so it is an average rate, not a
// slew. No row yet — the slider is a one-line follow-on now that the knob exists.
("cameraYawSmoothSpeed", "180"),
// Status Text (1140): 1.12's `statusBarText`, the "always show value / max on a status bar"
// switch. **No host knob** — its consumer is Lua (TextStatusBar.xml, decision 1082, which was
// written waiting for this key and reads it on every repaint). Default "0": the reference's
@@ -241,7 +252,7 @@ struct Knobs<'a> {
minimap: &'a mut MinimapZoom,
bubbles: &'a mut BubbleConfig,
zoom: &'a mut ZoomLimit,
follow: &'a mut FollowStyle,
follow: &'a mut FollowConfig,
video: &'a mut VideoConfig,
pane_rate: &'a mut PaneRate,
}
@@ -271,7 +282,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"cameradistancemaxfactor" => knobs.zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
// default rather than as a dead camera (`FollowStyle::from_cvar`).
"camerasmoothstyle" => *knobs.follow = FollowStyle::from_cvar(v),
"camerasmoothstyle" => knobs.follow.style = FollowStyle::from_cvar(v),
// Its sibling selector — the one the reference swaps in for the externally-driven states.
"camerasmoothtrackingstyle" => knobs.follow.tracking_style = FollowStyle::from_cvar(v),
// The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range.
"camerayawsmoothspeed" => {
knobs.follow.yaw_speed =
v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end());
}
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
"mousespeed" => {
knobs.look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
@@ -332,7 +350,7 @@ fn load_config(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowStyle>,
mut follow: ResMut<FollowConfig>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
@@ -427,7 +445,7 @@ fn sync_cvars(
mut minimap: ResMut<MinimapZoom>,
mut bubbles: ResMut<BubbleConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowStyle>,
mut follow: ResMut<FollowConfig>,
mut video: ResMut<VideoConfig>,
mut pane_rate: ResMut<PaneRate>,
) {
@@ -449,7 +467,7 @@ fn sync_cvars(
);
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 26] = [
let session: [(&str, String); 28] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -464,7 +482,12 @@ fn sync_cvars(
("mouseInvertPitch", flag(look.invert_pitch)),
("mousespeed", look.sensitivity.to_string()),
("cameraDistanceMaxFactor", zoom.factor().to_string()),
("cameraSmoothStyle", follow.cvar().to_string()),
("cameraSmoothStyle", follow.style.cvar().to_string()),
(
"cameraSmoothTrackingStyle",
follow.tracking_style.cvar().to_string(),
),
("cameraYawSmoothSpeed", follow.yaw_speed.to_string()),
("autoLootDefault", flag(loot.auto_loot)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
@@ -548,7 +571,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
Option<ResMut<MinimapZoom>>,
Option<ResMut<BubbleConfig>>,
Option<ResMut<ZoomLimit>>,
Option<ResMut<FollowStyle>>,
Option<ResMut<FollowConfig>>,
Option<ResMut<VideoConfig>>,
Option<ResMut<PaneRate>>,
)> = bevy::ecs::system::SystemState::new(world);
@@ -756,13 +779,19 @@ mod tests {
);
assert_eq!(d["mousespeed"], LookConfig::default().sensitivity);
assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor());
// The following style (1493) welds to the enum's own Default — and BOTH sides are the
// reference's registrar "1" = Smart, the one row in this arc that agrees with the binary
// outright.
// The follow trio (1493/1502) welds to FollowConfig's own defaults — and all three ARE
// the binary's registrar values, byte-verified: "1"/"1"/"180.0". The one corner of this
// arc that agrees with the reference outright.
let follow = FollowConfig::default();
assert_eq!(
d["cameraSmoothStyle"],
FollowStyle::default().cvar().parse::<f32>().unwrap()
follow.style.cvar().parse::<f32>().unwrap()
);
assert_eq!(
d["cameraSmoothTrackingStyle"],
follow.tracking_style.cvar().parse::<f32>().unwrap()
);
assert_eq!(d["cameraYawSmoothSpeed"], follow.yaw_speed);
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot);
// The name trio (0992) welds to NameConfig's defaults the same way.
@@ -810,7 +839,7 @@ mod tests {
let mut minimap = MinimapZoom::default();
let mut bubbles = BubbleConfig::default();
let mut zoom = ZoomLimit::default();
let mut follow = FollowStyle::default();
let mut follow = FollowConfig::default();
let mut video = VideoConfig::default();
let mut pane_rate = PaneRate::default();
let mut knobs = Knobs {
@@ -851,14 +880,25 @@ mod tests {
assert_eq!(knobs.look.sensitivity, 1.4);
assert!(apply_to_knobs("mousespeed", "9", &mut knobs));
assert_eq!(knobs.look.sensitivity, 1.5);
// The following style lands as the enum, on the reference's own numbering — and an
// off-grid value reads as the registrar default rather than as a dead camera.
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Never);
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Always);
// The following style lands as the ENGINE's enum (0 Never / 1 Smart / 2 Always), and the
// "3" the reference's own dropdown writes for Never still means Never.
assert!(apply_to_knobs("cameraSmoothStyle", "0", &mut knobs));
assert_eq!(*knobs.follow, FollowStyle::Smart);
assert_eq!(knobs.follow.style, FollowStyle::Never);
assert!(apply_to_knobs("camerasmoothstyle", "2", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Always);
assert!(apply_to_knobs("cameraSmoothStyle", "3", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Never);
assert!(apply_to_knobs("cameraSmoothStyle", "1", &mut knobs));
assert_eq!(knobs.follow.style, FollowStyle::Smart);
// Its two siblings land on the same knob — the tracking selector, and the rate, which
// clamps to 1.12's AUTO_FOLLOW_SPEED slider range.
assert!(apply_to_knobs("cameraSmoothTrackingStyle", "2", &mut knobs));
assert_eq!(knobs.follow.tracking_style, FollowStyle::Always);
assert_eq!(knobs.follow.style, FollowStyle::Smart, "and only that one");
assert!(apply_to_knobs("cameraYawSmoothSpeed", "270", &mut knobs));
assert_eq!(knobs.follow.yaw_speed, 270.0);
assert!(apply_to_knobs("cameraYawSmoothSpeed", "9000", &mut knobs));
assert_eq!(knobs.follow.yaw_speed, *FOLLOW_SPEED_RANGE.end());
// The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2.
assert!(apply_to_knobs("cameraDistanceMaxFactor", "1", &mut knobs));
assert_eq!(knobs.zoom.max, 15.0);
@@ -953,7 +993,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowStyle>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
@@ -1024,7 +1064,7 @@ mod tests {
.init_resource::<MinimapZoom>()
.init_resource::<BubbleConfig>()
.init_resource::<ZoomLimit>()
.init_resource::<FollowStyle>()
.init_resource::<FollowConfig>()
.init_resource::<VideoConfig>()
.init_resource::<PaneRate>()
.add_plugins(CvarPlugin);
+7
View File
@@ -203,6 +203,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_DRESS_CENSUS").is_ok() {
app.add_plugins(crate::capture::DressCensusPlugin);
}
// The reveal audit: `WOW_REVEAL=<frames>` prints one line per frame from a snap —
// the cover, every residency term behind `presentable()`, the settle hold and the
// retained pass's collected-vs-published regions. The instrument for "the teleport
// showed me a world with no buildings in it" (see `capture::RevealAuditPlugin`).
if std::env::var("WOW_REVEAL").is_ok() {
app.add_plugins(crate::capture::RevealAuditPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
+42 -1
View File
@@ -69,6 +69,14 @@ const CLEAR_AFTER_READY_FRAMES: u32 = 3;
/// nothing extra.
const WAIT_LOG_AFTER: f32 = 3.0;
const WAIT_LOG_EVERY: f32 = 2.0;
/// How far a same-map snap must move the body before its destination is treated as a **load** at
/// all (yards). Under this, a teleport cannot outrun the streamer — the ground and buildings you
/// land on are the ones you left — and the raise below would only be able to flash a cover over
/// content that happened to be arriving anyway. Sized to clear every *combat* relocation with
/// room to spare (charge/intercept 25 yd, blink 20 yd, the knockbacks under 30), because those
/// end in a server teleport too and a black screen mid-fight is the one thing this must never
/// do; the reported case — `.tele` across a city — is 458 yd.
const SNAP_LOAD_MIN_YD: f32 = 100.0;
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
/// path table). Paired with [`MapCatalogRes`] (the `mapId` → `LoadingScreenID` FK) to resolve art.
@@ -109,6 +117,10 @@ pub(crate) struct LoadingScreen {
/// [`WAIT_LOG_AFTER`]).
active_since: f32,
last_wait_log: f32,
/// The avatar's position as of LAST frame — so a snap's own displacement is knowable here
/// (the snap is applied in `Input`, a stage before this one). Read only by the snap raise
/// below, which uses it to tell a relocation from a spell's little hop.
last_pos: Option<Vec3>,
}
impl LoadingScreen {
@@ -385,7 +397,8 @@ fn drive_loading_screen(
for w in worldports.read() {
screen.raise("worldport", false, Some(w.map_id), now);
}
if teleports.read().next().is_some() {
let teleported = teleports.read().next().is_some();
if teleported {
screen.awaiting_snap = false;
screen.pending_map = None;
}
@@ -437,6 +450,34 @@ fn drive_loading_screen(
screen.raise("focus not resident", false, None, now);
}
// --- …and the same test at the SNAP, on everything the reveal actually needs. The backstop
// above watches one term — the focus TERRAIN tile — and a teleport inside the streamer's keep
// band (up to ~1600 yd, three tiles) lands on ground that is already resident, so it never
// fires. The destination's *buildings* are a different question: their placements may still be
// spawning, and since the retained pass (1429) a spawned building still has a bake between it
// and the screen. That is how `.tele` across a city put the player down in a Stormwind with no
// Stormwind in it, uncovered, for the frames it took to arrive.
//
// Only at a snap, and only against the same predicate that CLEARS the screen: a summon across
// a room whose world is already there raises nothing (no flash), and ordinary walking — which
// crosses tile lines with placements pending all day — is not a snap and cannot reach this. ---
let body = player.as_ref().map(|p| p.pos);
let relocated = match (teleported, screen.last_pos, body) {
(true, Some(was), Some(now_pos)) => was.distance(now_pos) >= SNAP_LOAD_MIN_YD,
// No previous position to compare (the entry frame): treat the snap as a relocation, the
// conservative arm — a covered load is recoverable, a naked one is what this closes.
(true, _, _) => true,
_ => false,
};
screen.last_pos = body;
if relocated
&& !screen.active
&& *state.get() == crate::char_select::ClientState::InWorld
&& !(progress.is_ready() && progress.presentable())
{
screen.raise("teleport, destination not presentable", false, None, now);
}
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
// placements + colliders — [`WorldLoadProgress::presentable`]), and the physics hold is done,
// sustained a few frames. ---
+16
View File
@@ -290,6 +290,16 @@ pub(crate) struct WarmPass {
effect_tex: Option<Handle<Image>>,
/// Consecutive covered+in-world frames seen while idle (see [`WARM_COVER_PRESENT_FRAMES`]).
covered_frames: u32,
/// **The menagerie has drained cleanly once in this process.** The warm set is a fixed
/// variant cross built from `Startup`-populated stores (see `menagerie`) — not the resident
/// map's art — and `PipelineCache` is process-global and never evicts, so a second pass
/// compiles nothing. Re-running it under every later cover was pure cost, and *visible*
/// cost: it holds the cover (`satisfied`) for the whole paced reveal, which measured 3.8 s
/// on a mid-session teleport whose world was resident in 0.7 s. Later covers therefore skip
/// the pass. If that is ever wrong the tripwire says so out loud — `PipeWatch`'s "compiled
/// 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,
/// 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,
@@ -410,6 +420,11 @@ fn run_warm_pass(
return;
}
let now = time.elapsed_secs();
// Already warmed in this process: nothing to compile, so nothing to hold the cover for.
if warm.warmed_once {
warm.done = true;
return;
}
let Some(spawned) = warm.spawned_at else {
// The cover just rose (or the world just became live under one): raise the gate and
// spawn the menagerie once the camera + shared light exist (both are entry-frame-early;
@@ -507,6 +522,7 @@ fn run_warm_pass(
.saturating_sub(watch.0.settled.load(Ordering::Relaxed));
if all_revealed && pending == 0 && now - last_reveal >= WARM_SETTLE_SECS {
warm.done = true;
warm.warmed_once = true;
warm.effect_tex = None;
despawn_rigs(&mut commands, &rigs);
info!(
+84 -22
View File
@@ -189,7 +189,7 @@ impl Plugin for PlayerPlugin {
);
app.init_resource::<camera::LookConfig>();
app.init_resource::<camera::ZoomLimit>();
app.init_resource::<camera::FollowStyle>();
app.init_resource::<camera::FollowConfig>();
// Far sight: resolve `PLAYER_FARSIGHT` into a pose before `control` reads it to seat the
// camera. A separate system rather than another query on `control` for a hard reason —
// `control` already holds the self entity's `Transform` mutably, so it cannot also read an
@@ -351,7 +351,7 @@ fn control(
Res<AccumulatedMouseMotion>,
Res<camera::LookConfig>,
Res<camera::ZoomLimit>,
Res<camera::FollowStyle>,
Res<camera::FollowConfig>,
),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
@@ -493,15 +493,24 @@ fn control(
let view_subject = &speed_capsule.8;
let self_guid = speed_capsule.9 .0;
let scoped = &speed_capsule.10;
// The auto-follow style (decision 1493), with far sight's one exception folded in here so both
// camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
// Totem), our own facing is not what "behind" means, so the chase is forced off rather than
// The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so
// both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry
// Totem), our own facing is not what "behind" means, so the return is forced off rather than
// reeling that camera toward a heading with nothing to do with the view.
let follow_style = if view_subject.remote.is_some() {
camera::FollowStyle::Never
} else {
*pointer.3
let follow_cfg = camera::FollowConfig {
style: if view_subject.remote.is_some() {
camera::FollowStyle::Never
} else {
pointer.3.style
},
tracking_style: if view_subject.remote.is_some() {
camera::FollowStyle::Never
} else {
pointer.3.tracking_style
},
..*pointer.3
};
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
@@ -522,6 +531,61 @@ fn control(
let both_buttons =
(buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right)) || steer_held;
// The camera's **input command word** (decision 1502) — 1.12's `[InputControl+0x4]`, bit for
// bit. The auto-follow is armed by *edges on this word* and its state is classified from it, so
// it is built once here, from the same binding state the movement code below reads, and handed
// to both camera seats. MOVEANDSTEER sets both mouse bits because that is what the reference's
// binding does — it runs the identical CameraOrSelectOrMove + TurnOrAction pair.
let follow_command = {
use camera::follow_cmd as bit;
let mut w = 0;
let mut set = |on: bool, b: u32| {
if on {
w |= b;
}
};
set(
buttons.pressed(MouseButton::Right) || steer_held,
bit::RIGHT_MOUSE,
);
set(
buttons.pressed(MouseButton::Left) || steer_held,
bit::LEFT_MOUSE,
);
// `/follow` is the forward bit in the reference too — the same setter the W key drives
// (decision 0890), so it arms the camera exactly like a held W.
set(
binds.pressed(crate::bindings::cmd::MOVE_FORWARD) || player.follow_forward,
bit::FORWARD,
);
set(
binds.pressed(crate::bindings::cmd::MOVE_BACKWARD),
bit::BACKWARD,
);
set(
binds.pressed(crate::bindings::cmd::STRAFE_LEFT),
bit::STRAFE_LEFT,
);
set(
binds.pressed(crate::bindings::cmd::STRAFE_RIGHT),
bit::STRAFE_RIGHT,
);
set(
binds.pressed(crate::bindings::cmd::TURN_LEFT),
bit::TURN_LEFT,
);
set(
binds.pressed(crate::bindings::cmd::TURN_RIGHT),
bit::TURN_RIGHT,
);
set(player.autorun, bit::AUTORUN);
// The two externally-driven flags, which the reference folds into the camera's own
// Track/Fear bits rather than the input word — carried here so one word carries every edge.
set(player.server_riding, bit::TRACK);
set(player.control_lost, bit::FEAR);
w
};
// The look session gets a SHADOW copy of `CursorOptions`, written back only on a real change:
// handing it the component's `Mut` directly reborrowed mutably every frame, which marks it
// Changed regardless of writes — and bevy_winit's `changed_cursor_options` then re-applied
@@ -733,16 +797,9 @@ fn control(
// `reseat` window is excluded — there the resource still describes the body we are
// letting go of, and `apply_server_moves` owns the adoption.
//
// The same sync answers the auto-follow's Smart gate (1493): is the body we are NOT
// driving nevertheless moving? A taxi, a Charge, a knockback or a fear all translate
// the avatar while the controller stands down, and a camera that ignored them would
// sit at the angle you left it while the body ran out from under it. A spline ride is
// translation by definition; a driven body is measured against the pose we held.
let mut driven_moving = player.server_riding;
if player.control_lost && !player.reseat {
if let Ok((_, t, ..)) = body.single() {
let yaw = server_ride::yaw_of(t.rotation);
driven_moving |= t.translation.distance_squared(player.pos) > 1.0e-6;
player.pos = t.translation;
player.face_yaw = yaw;
player.model_yaw = yaw;
@@ -772,10 +829,15 @@ fn control(
&mut cam_t,
&collide,
cam_probe,
// The auto-follow still runs here — a taxi, a Charge, a knockback or a fear
// all translate the avatar while the controller stands down, and the reference
// has states for exactly those (`Track`, `Fear`: a 0.4 s delay and a lazy 18 °/s
// return under Smart). The word carries both flags, so the edge into and out of
// one of them is what arms it.
&camera::FollowInput {
style: follow_style,
cfg: follow_cfg,
face_yaw: player.face_yaw,
moving: driven_moving,
command: follow_command,
},
);
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
@@ -1706,12 +1768,12 @@ fn control(
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
// carries the camera unconditionally; only input turns respect `seat_camera`'s
// look-session gate).
// The auto-follow's own three facts (decision 1493): the player's style, where "behind"
// is, and whether the character is translating — Smart's gate.
// The auto-follow's own three facts (decisions 1493/1502): the knobs, where "behind" is,
// and the input word whose edges arm a return.
let follow = camera::FollowInput {
style: follow_style,
cfg: follow_cfg,
face_yaw: player.face_yaw,
moving,
command: follow_command,
};
seat_camera(
dt,
+573 -89
View File
@@ -195,43 +195,65 @@ impl LookConfig {
LOOK_SENSITIVITY * self.sensitivity
}
}
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, whose registrar default is
/// **180 °/s** (`WoW.exe` `[0xbe1070]`, the yaw transition channel's own CVar float — wow-re
/// `ui/scratch/camera-control.md`). The reference exposes it as the AUTO_FOLLOW_SPEED slider
/// (`UIOptionsFrameSliders`, 90 … 270 °/s by 10) and *disables that slider* when the style is
/// Never; benilla ships the registrar default as a constant, since it has no row for it yet.
const FOLLOW_RATE: f32 = std::f32::consts::PI;
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, registrar default **180 °/s**
/// (`WoW.exe` `[0xbe1070]`, read at `0x512d75`). It is not a slew rate but a *duration* divisor:
/// the transition below lasts `|Δyaw| / rate × factor`, so 180 °/s is the **average** rate at
/// factor 1, and the cosine profile peaks at π/2 × that. 1.12 exposes it as the AUTO_FOLLOW_SPEED
/// slider (`UIOptionsFrameSliders`, 90 … 270 by 10), disabled while the style is Never.
pub(crate) const FOLLOW_SPEED_DEFAULT: f32 = 180.0;
/// The 1.12 slider's own range for [`FollowConfig::yaw_speed`].
pub(crate) const FOLLOW_SPEED_RANGE: std::ops::RangeInclusive<f32> = 90.0..=270.0;
/// `cameraSmoothTimeMin`/`Max` — the transition's duration floor and ceiling, **VERIFIED
/// 0.1 s / 2.0 s** (`[0xbe105c]`/`[0xbe1038]`, clamped at `0x510f4d`). These are why the felt
/// rate is *not* 180 °/s at the ends: a 5° correction still takes 0.1 s, and a lazy `Track`
/// return (factor 10) is capped at 2 s however far it has to come.
const FOLLOW_TIME_MIN: f32 = 0.1;
const FOLLOW_TIME_MAX: f32 = 2.0;
/// The "already there / already arming this" epsilon — the reference's own `0.001`
/// (`[0x801360]`, `0x512ce4`/`0x512d41`). A float-equality guard, not a perceptible deadzone.
const FOLLOW_EPS: f32 = 1.0e-3;
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decision 1493): does the camera swing
/// back behind the character on its own?
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decisions 1493/1502): does the camera
/// return to behind the character on its own?
///
/// benilla shipped the reference behaviour *removed* from the day the camera was written (the
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, and
/// the director's call now is the reference's own default: **Smart**.
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, at
/// the reference's own default: **Smart**.
///
/// The three values are the reference's, dropdown order and all (`UIOptionsFrameCameraDropDown`,
/// which writes this CVar): `"1"` Smart, `"2"` Always, `"3"` Never — note that Never is **3**, not
/// 0. The registrar default is `"1"`.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
/// **The enum is the ENGINE's, `0 = Never · 1 = Smart · 2 = Always`** — byte-verified twice over
/// (wow-re `ui/scratch/camera-smooth-style.md` §2: the registration loop walks
/// `{"Never","Smart","Always"}` filling three blocks in order, and both consumers index by
/// `style × stride`). 1.12's own *dropdown* writes `1/2/3` instead (`UIOptionsFrameCameraDropDown`
/// — Smart 1, Always 2, Never **3**), and 3 is not a style at all: the validator accepts it
/// (`0x50b330(v, 0, 3)`) but the terrain-tilt consumer does not bound-check and indexes 360 bytes
/// past its table. So the reference's shipped UI writes an out-of-range value for Never, and a
/// client must clamp to 0..2. We do: our own dropdown writes the engine's numbers, and a stray
/// `3` is *read* as Never — what whoever wrote it meant.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum FollowStyle {
/// Stay where placed, *except while the character is moving* — the reference's own
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
/// benilla did unconditionally before 1493. It does **not** stop the rigid keyboard-turn
/// carry — in the reference that is a different mechanism entirely (the camera yaw is stored
/// *relative* to the followed unit's facing), and Never leaves it running.
Never,
/// Stay where placed, *except* while the character is being driven — the reference's own
/// "(Recommended Mode)" and its registrar default.
#[default]
Smart,
/// Always prefer being behind the character.
/// Always prefer being behind the character: every input edge arms a return, standing still
/// included.
Always,
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
/// benilla did unconditionally before 1493.
Never,
}
impl FollowStyle {
/// From the CVar's number. Anything off the three stops reads as the registrar default —
/// a hand-edited config or an addon's stray write gets the shipped behaviour, not a dead
/// camera.
/// From the CVar's number. `3` is the shipped-UI's Never (see the type doc); anything else off
/// the ladder reads as the registrar default rather than as a dead camera.
pub(crate) fn from_cvar(v: f32) -> Self {
match v as i32 {
0 => Self::Never,
2 => Self::Always,
// The 1.12 dropdown's own Never. Out of range for the engine's tables — accepted here
// because a config or addon carrying it means Never, not "surprise me".
3 => Self::Never,
_ => Self::Smart,
}
@@ -240,47 +262,292 @@ impl FollowStyle {
/// The CVar string this style is — the value the table and `config.toml` carry.
pub(crate) fn cvar(self) -> &'static str {
match self {
Self::Never => "0",
Self::Smart => "1",
Self::Always => "2",
Self::Never => "3",
}
}
/// Should the camera chase the character's facing this frame? `moving` is the character's own
/// translation intent (Smart's gate — the reference's "except when your character is moving").
fn chases(self, moving: bool) -> bool {
/// The `cameraSmooth<Style><State>{Delay,Factor}` row — family A at `[0xbe0e70]`, dumped at
/// its defaults in wow-re `camera-smooth-style.md` §3. Factor `0` means *cancel*: the armed
/// transition is dropped and the camera keeps the offset it has.
///
/// Family B (`cameraSmoothViewData<Style>Yaw{Delay,Factor}`) multiplies in: its Yaw factor is
/// `1.0` under Smart and Always and `0.0` under Never, and its delay is `0.0` at every style —
/// so for the yaw channel the composition is the identity and the table below is the answer.
fn row(self, state: FollowState) -> (f32, f32) {
match self {
Self::Always => true,
Self::Smart => moving,
Self::Never => false,
// Every row is 0/0 — and family B's Never Yaw factor is 0.0 as well, twice over.
Self::Never => (0.0, 0.0),
Self::Smart => match state {
// The two states that make Smart *smart*: nothing returns while you stand or stop.
FollowState::Idle | FollowState::Stop => (0.0, 0.0),
// Driven from outside (a taxi, a spline, a fear): a lazy return — 0.4 s of delay
// and factor 10, which is 18 °/s of average rate before the 2 s cap bites.
FollowState::Track | FollowState::Fear => (0.4, 10.0),
FollowState::Move | FollowState::Strafe | FollowState::Turn => (0.0, 1.0),
},
// Always is factor 1.0 in every state, Idle and Stop included.
Self::Always => (0.0, 1.0),
}
}
}
/// What [`seat_camera`] needs to run the auto-follow this frame — the style, where "behind" is,
/// and whether the character is moving. Bundled because the three are one question, and
/// The seven arming states of 1.12's auto-return classifier (`0x510960`, wow-re
/// `camera-smooth-style.md` §6.2), **in the reference's own priority order — highest first**. The
/// winner is the highest-priority bit set, and the states are read off the *camera's* input
/// command word, not off the character's velocity: right-mouse alone is a `Turn`, a turn key
/// under right-mouse is a `Strafe`, and both mouse buttons are a `Move`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum FollowState {
/// Driven by something that is not us and is not a spline — the reference's external-control
/// flag `[cam+0x90] & 0x1000`.
Fear,
Turn,
Strafe,
Move,
/// Externally-driven movement (taxi / server spline) — `[cam+0x90] & 0x100`.
Track,
/// This edge released a movement input (the reference's `stopping` argument). Carries the same
/// `(0, 0)` / `(0, 1)` rows as [`FollowState::Idle`] at every shipped style, so the two are
/// currently indistinguishable in behaviour — kept apart because the *matrix* keeps them apart.
Stop,
Idle,
}
/// The camera's input command word — 1.12's `[InputControl+0x4]` bit for bit (wow-re
/// `camera-smooth-style.md` §6.1), because the state classifier and the *edge* that arms a
/// transition are both defined on it. benilla has no PitchUp/PitchDown bindings, so those two bits
/// are simply never set.
pub(super) mod follow_cmd {
/// `TurnOrAction` — right mouse / mouselook.
pub(in crate::player) const RIGHT_MOUSE: u32 = 0x1;
/// `CameraOrSelectOrMove` — left mouse.
pub(in crate::player) const LEFT_MOUSE: u32 = 0x2;
pub(in crate::player) const FORWARD: u32 = 0x10;
pub(in crate::player) const BACKWARD: u32 = 0x20;
pub(in crate::player) const STRAFE_LEFT: u32 = 0x40;
pub(in crate::player) const STRAFE_RIGHT: u32 = 0x80;
pub(in crate::player) const TURN_LEFT: u32 = 0x100;
pub(in crate::player) const TURN_RIGHT: u32 = 0x200;
pub(in crate::player) const AUTORUN: u32 = 0x1000;
/// The union of bits 20/21/23, which the reference folds into the camera's `Track` flag —
/// externally-driven movement.
pub(in crate::player) const TRACK: u32 = 0x100000;
/// External control (the reference's own `[cam+0x90] & 0x1000`, not an InputControl bit —
/// carried here so one word carries every edge the arming function reacts to).
pub(in crate::player) const FEAR: u32 = 0x2000_0000;
/// The bits `Move` reads: forward, backward, autorun.
pub(in crate::player) const MOVE_BITS: u32 = FORWARD | BACKWARD | AUTORUN;
pub(in crate::player) const STRAFE_BITS: u32 = STRAFE_LEFT | STRAFE_RIGHT;
pub(in crate::player) const TURN_BITS: u32 = TURN_LEFT | TURN_RIGHT;
}
/// The knobs behind the auto-follow (decision 1502) — the style, the style the *externally-driven*
/// states use instead, and the rate. All three are 1.12 CVars with the reference's own defaults.
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct FollowConfig {
/// `cameraSmoothStyle`.
pub(crate) style: FollowStyle,
/// `cameraSmoothTrackingStyle` — the selector the reference swaps in when the state mask so
/// much as *contains* `Track` or `Fear` (`0x510a51 test bl,0x44`, before the priority scan),
/// indexing the very same matrices. Registrar default `"1"` = Smart, like its sibling.
pub(crate) tracking_style: FollowStyle,
/// `cameraYawSmoothSpeed`, °/s — see [`FOLLOW_SPEED_DEFAULT`].
pub(crate) yaw_speed: f32,
}
impl Default for FollowConfig {
fn default() -> Self {
Self {
style: FollowStyle::default(),
tracking_style: FollowStyle::default(),
yaw_speed: FOLLOW_SPEED_DEFAULT,
}
}
}
/// What [`seat_camera`] needs to run the auto-follow this frame: the knobs, where "behind" is, and
/// the input word whose *edges* arm a return. Bundled because they are one question, and
/// `seat_camera` is already at its argument ceiling.
pub(super) struct FollowInput {
pub(super) style: FollowStyle,
pub(super) cfg: FollowConfig,
/// The character's own facing (same yaw convention as [`FlyCam::yaw`] — a right-drag couples
/// the two directly).
/// the two directly), so the camera's *offset* is `cam.yaw − face_yaw`.
pub(super) face_yaw: f32,
/// Is the character translating this frame? (Turn-in-place is not movement here: a keyboard
/// turn already carries the camera rigidly, so there is no offset left for the chase.)
pub(super) moving: bool,
/// This frame's [`follow_cmd`] word.
pub(super) command: u32,
}
impl FollowInput {
/// The yaw this frame's chase adds — the shortest arc to the character's facing, capped at
/// [`FOLLOW_RATE`]·dt so the camera *slews* rather than snapping, and exactly zero on a style
/// (or a stillness) that isn't chasing. The cap stops **at** the target: the clamp can never
/// overshoot, so a settled camera writes nothing and the no-op gate downstream stays quiet.
fn step(&self, cam_yaw: f32, dt: f32) -> f32 {
if !self.style.chases(self.moving) {
return 0.0;
/// The winning state — the reference's descending priority scan, highest bit first.
fn state(&self, stopping: bool) -> FollowState {
let mf = self.command;
let held = |bits: u32| mf & bits != 0;
if held(follow_cmd::FEAR) {
FollowState::Fear
} else if held(follow_cmd::TURN_BITS) || held(follow_cmd::RIGHT_MOUSE) {
FollowState::Turn
} else if held(follow_cmd::STRAFE_BITS)
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::TURN_BITS))
{
FollowState::Strafe
} else if held(follow_cmd::MOVE_BITS)
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::LEFT_MOUSE))
{
FollowState::Move
} else if held(follow_cmd::TRACK) {
FollowState::Track
} else if stopping {
FollowState::Stop
} else {
FollowState::Idle
}
let step = FOLLOW_RATE * dt;
wrap_pi(self.face_yaw - cam_yaw).clamp(-step, step)
}
/// Which style picks the row: the *tracking* style whenever the mask so much as contains
/// `Track` or `Fear`, even when a higher-priority state supplies the row.
fn style(&self) -> FollowStyle {
if self.command & (follow_cmd::TRACK | follow_cmd::FEAR) != 0 {
self.cfg.tracking_style
} else {
self.cfg.style
}
}
}
/// The armed yaw transition — the reference's descriptor `[+0x208 startMs, +0x20c dur,
/// +0x210 target, +0x214 start]`, in seconds and in *offset* space.
#[derive(Clone, Copy, Debug)]
struct FollowArm {
/// The offset the transition started from, radians (camera yaw minus character facing).
from: f32,
/// Where it is going. `0.0` — directly behind — at the shipped defaults, because
/// `cameraYawSmoothMin`/`Max` are both `0.0` and the reference *substitutes* the crossed bound
/// for the saved view yaw whenever the live offset is outside the band.
to: f32,
/// Seconds the move takes, already clamped to `[FOLLOW_TIME_MIN, FOLLOW_TIME_MAX]`.
dur: f32,
/// Seconds of dead time before it starts (`Track`/`Fear` under Smart: 0.4).
delay: f32,
/// Seconds since it was armed.
elapsed: f32,
/// What it was armed with — the reference's re-arm memo (`[+0x218, +0x21c]`), so a repeated
/// edge asking for the same transition is a no-op instead of restarting the swing.
armed_with: (f32, f32),
}
/// The auto-follow's own state on the rig: the input word we last saw (edges are what arm a
/// return) and the transition in flight, if any.
#[derive(Default)]
pub(super) struct FollowRig {
last_command: Option<u32>,
arm: Option<FollowArm>,
}
impl FollowRig {
/// Run the auto-follow for a frame and return the camera yaw it wants, if it wants one.
///
/// The shape is the reference's, and the shape is the point (wow-re `camera-smooth-style.md`
/// §6/§8): a transition is **armed on an input edge**, from a snapshot taken at that instant,
/// and then plays out unattended — it is *not* a per-frame chase of a moving target. That is
/// why "drag the camera aside, then press W" swings you back over one smooth arc, while
/// holding W changes nothing at all.
fn advance(
&mut self,
input: &FollowInput,
cam_yaw: f32,
dt: f32,
look_held: bool,
) -> Option<f32> {
let word = input.command;
let previous = self.last_command.replace(word);
// A held drag owns the camera: the yaw channel is frozen (`0x50f623`), arming is gated
// (`0x510850`'s `!([cam+0x90] & 1)`), and **entering** mouse-look cancels whatever was in
// flight outright (`0x50fe30` zeroes the descriptors). So the return does not resume when
// the button comes up — it begins at the next input edge after the release, which is
// usually the release itself (the mouse bits are part of the word).
if look_held {
self.arm = None;
return None;
}
// The EDGE: any movement/camera binding changing state re-evaluates the transition. The
// reference re-evaluates on the binding call itself, not on a change of the classified
// state, which is why releasing the drag while standing still is an Idle *arming* under
// Always and an Idle *cancel* under Smart.
if let Some(p) = previous.filter(|p| *p != word) {
// `stopping` — the reference's second argument, set by the Stop half of a movement
// binding. Our equivalent is the edge itself: a movement bit went away.
let stopping = (p & !word)
& (follow_cmd::MOVE_BITS | follow_cmd::STRAFE_BITS | follow_cmd::TURN_BITS)
!= 0;
self.arm(input, cam_yaw, stopping);
}
let arm = self.arm.as_mut()?;
arm.elapsed += dt;
let t = arm.elapsed - arm.delay;
if t < 0.0 {
return None; // still inside the delay window
}
let s = t / arm.dur;
let offset = if s >= 1.0 {
let to = arm.to;
self.arm = None;
to
} else {
// The reference's kernel `0x5b7bb0` — a **cosine** smoothstep, eased at both ends:
// `a + (b − a)·(1 − cos(πs))/2`.
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
arm.from + (arm.to - arm.from) * e
};
Some(wrap_pi(input.face_yaw + offset))
}
/// What the transition is doing, for `WOW_CAM_DUMP` — `None` when nothing is armed, else
/// `(elapsed, delay, duration)` in seconds. The auto-follow is the one camera behaviour with
/// no headless retest (nothing synthesizes a mouse drag), so the trace line is how a run says
/// what it armed and when.
fn probe(&self) -> Option<(f32, f32, f32)> {
self.arm.map(|a| (a.elapsed, a.delay, a.dur))
}
/// The arming half (`0x510960` → `0x512c70`): pick the row, then either cancel outright or
/// snapshot a transition to the target offset.
fn arm(&mut self, input: &FollowInput, cam_yaw: f32, stopping: bool) {
let (delay, factor) = input.style().row(input.state(stopping));
if factor == 0.0 {
// Cancel: the target becomes the live yaw and the channel disarms — the camera simply
// keeps the offset it has. This is Smart standing still, and it is all of Never.
self.arm = None;
return;
}
// The target: directly behind, at the shipped defaults (see [`FollowArm::to`]).
let to = 0.0;
let from = wrap_pi(cam_yaw - input.face_yaw);
let gap = (to - from).abs();
if gap < FOLLOW_EPS {
return; // already there — the reference returns without arming
}
// The re-arm memo: an edge asking for the transition already in flight is a no-op, so a
// second keypress mid-swing does not restart it from the current angle.
if self.arm.is_some_and(|a| {
a.to == to
&& (a.armed_with.0 - delay).abs() < FOLLOW_EPS
&& (a.armed_with.1 - factor).abs() < FOLLOW_EPS
}) {
return;
}
let rate = input.cfg.yaw_speed.to_radians().max(FOLLOW_EPS);
let dur = (gap / rate * factor).clamp(FOLLOW_TIME_MIN, FOLLOW_TIME_MAX);
self.arm = Some(FollowArm {
from,
to,
dur,
delay,
elapsed: 0.0,
armed_with: (delay, factor),
});
}
}
@@ -355,6 +622,10 @@ pub(crate) struct CameraControl {
/// the camera zooms into the head (first-person). `control` computes it (it owns the pivot + camera
/// pose); [`apply_self_model_fade`] applies it to the body parts. Starts opaque.
pub(super) self_fade_alpha: f32,
/// The auto-follow's own state (decision 1502): the input word we last saw, and the armed
/// return in flight. It lives on the rig rather than beside the knob because it is *pose*, not
/// setting — a transition survives the frame, not the session.
pub(super) follow: FollowRig,
}
impl CameraControl {
@@ -646,14 +917,19 @@ pub(super) fn seat_camera(
// — no INPUT-turn carry against the user's hand. (A transport deck's turn is not an input and
// never arrives here — the ride block applies it to `cam.yaw` directly, drag or no drag.)
//
// **The auto-follow** (decision 1493, 1.12's `cameraSmoothStyle`) rides the same gate for the
// same reason — a held drag owns the camera, hand on it: swing the camera back behind the
// character, along the shortest arc, at [`FOLLOW_RATE`]. Which frames it runs on is the
// player's setting — Always every frame, Smart only while the character is translating, Never
// not at all (the behaviour benilla shipped unconditionally until now).
if rig.look.is_none() {
// **The auto-follow** (1.12's `cameraSmoothStyle`, decisions 1493/1502) rides the same gate
// for the same reason — a held drag owns the camera, hand on it. It is NOT a per-frame chase:
// an input edge arms a cosine-smoothstep return to directly-behind and that transition then
// plays out unattended ([`FollowRig::advance`]). It writes an absolute yaw because our camera
// stores one; the reference stores the *offset* and re-adds the facing at render time, which
// is the same picture and a different mechanism (wow-re `camera-smooth-style.md` §10 — and
// the reason Never must not, and here does not, touch the rigid carry above).
let look_held = rig.look.is_some();
if !look_held {
cam.yaw += turn_delta;
cam.yaw += follow.step(cam.yaw, dt);
}
if let Some(yaw) = rig.follow.advance(follow, cam.yaw, dt, look_held) {
cam.yaw = yaw;
}
// Orient the camera, then orbit it behind the avatar's torso. The framing **pivot** is
// `feet + cam_pivot_height` (model-derived, ~neck height — [`CameraPivot`]); the camera looks at
@@ -722,9 +998,15 @@ pub(super) fn seat_camera(
// measured. `open` is printed beside the eased arm so a hit/miss alternation in the CAST is
// visible even on a frame where the ease has not yet moved the camera far enough to see.
if std::env::var_os("WOW_CAM_DUMP").is_some() {
// `follow=` is the auto-follow's own reading (1502): the offset the return is animating,
// the state the input word classifies to, and — once armed — how far through the
// transition this frame is. `off` moving while `arm` reads `-` means something other than
// the follow moved the camera.
let (elapsed, delay, dur) = rig.follow.probe().unwrap_or((-1.0, -1.0, -1.0));
eprintln!(
"[cam] yaw {:.6} pitch {:.6} dist {:.6} open {:.6} coll {:.6} frac {:.6} \
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}]",
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}] \
follow off {:.6} state {:?} word {:06x} arm {:.3}/{:.3}+{:.3}",
cam.yaw,
cam.pitch,
rig.distance,
@@ -737,6 +1019,12 @@ pub(super) fn seat_camera(
translation.x.to_bits(),
translation.y.to_bits(),
translation.z.to_bits(),
wrap_pi(cam.yaw - follow.face_yaw),
follow.state(false),
follow.command,
elapsed,
dur,
delay,
);
}
@@ -1110,47 +1398,243 @@ mod tests {
assert_eq!(slow.rate(), LOOK_SENSITIVITY * 0.5);
}
/// **The auto-follow** (decision 1493) — 1.12's `cameraSmoothStyle`, the setting benilla spent
/// its whole life behaving as "Never". Four properties, and the last two are the ones a
/// re-derivation gets wrong: the style gate (Smart is *movement*-gated, Always is not, Never is
/// never), the rate cap, the fact that the chase takes the SHORT way round the ±π seam, and
/// that it stops exactly ON the facing rather than oscillating across it.
/// **The auto-follow** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, the setting benilla
/// spent its whole life behaving as "Never". The properties that a re-derivation gets wrong,
/// and that the byte-verified mechanism (wow-re `camera-smooth-style.md`) turns on:
/// it is armed by an input **edge** and then plays out unattended (not a per-frame chase of a
/// moving target), the profile is a **cosine** smoothstep, the duration is `|Δ| / rate ×
/// factor` **clamped to [0.1 s, 2.0 s]**, and Smart's `Idle`/`Stop` rows are a *cancel* — which
/// is what "stays where you put it, except while you're moving" actually is.
#[test]
fn the_auto_follow_chases_on_the_styles_that_chase_and_never_overshoots() {
const DT: f32 = 1.0 / 60.0;
let step = |style, moving, face_yaw, cam_yaw| {
FollowInput {
fn the_auto_follow_is_armed_by_an_input_edge_and_eases_home() {
const DT: f32 = 1.0 / 120.0;
// A quarter turn of orbit offset, left there by a drag.
const OFFSET: f32 = std::f32::consts::FRAC_PI_2;
fn cfg(style: FollowStyle) -> FollowConfig {
FollowConfig {
style,
face_yaw,
moving,
tracking_style: style,
yaw_speed: FOLLOW_SPEED_DEFAULT,
}
.step(cam_yaw, DT)
};
}
/// Run `secs` of frames at a fixed input word; returns the camera yaw it ends on.
fn run(rig: &mut FollowRig, cfg: FollowConfig, word: u32, cam_yaw: f32, secs: f32) -> f32 {
let mut yaw = cam_yaw;
for _ in 0..((secs / DT).round() as i32).max(0) {
let input = FollowInput {
cfg,
face_yaw: 0.0,
command: word,
};
if let Some(y) = rig.advance(&input, yaw, DT, false) {
yaw = y;
}
}
yaw
}
// Never is inert — the camera stays exactly where the hand left it, moving or not.
assert_eq!(step(FollowStyle::Never, true, 1.0, 0.0), 0.0);
// Smart is gated on the character actually translating ("except when your character is
// moving"); Always is not.
assert_eq!(step(FollowStyle::Smart, false, 1.0, 0.0), 0.0);
assert!(step(FollowStyle::Smart, true, 1.0, 0.0) > 0.0);
assert!(step(FollowStyle::Always, false, 1.0, 0.0) > 0.0);
// A big gap slews at the rate, not in one frame.
let capped = step(FollowStyle::Always, true, 3.0, 0.0);
assert!((capped - FOLLOW_RATE * DT).abs() < 1.0e-6, "{capped}");
// A gap smaller than one frame's slew lands ON the facing — no overshoot, and the next
// frame writes exactly nothing (which is what keeps the no-op transform gate quiet).
let small = 0.5 * FOLLOW_RATE * DT;
let landed = step(FollowStyle::Always, true, small, 0.0);
assert!((landed - small).abs() < 1.0e-6, "{landed} vs {small}");
assert_eq!(step(FollowStyle::Always, true, 0.0, 0.0), 0.0);
// Across the ±π seam the chase takes the short way: a facing at −3.1 from a camera at 3.1
// is 0.08 rad AHEAD, not 6.2 rad behind.
assert!(
step(FollowStyle::Always, true, -3.1, 3.1) > 0.0,
"the shortest arc, not the long way round"
// ── Smart: standing still with the camera dragged aside, nothing happens; the moment the
// W edge lands, one armed transition brings it home over |Δ|/180°/s = 0.5 s.
let mut rig = FollowRig::default();
let c = cfg(FollowStyle::Smart);
let parked = run(&mut rig, c, 0, OFFSET, 1.0);
assert_eq!(
parked, OFFSET,
"Smart standing still leaves the camera alone"
);
let half = run(&mut rig, c, follow_cmd::FORWARD, parked, 0.25);
assert!(
half < OFFSET * 0.75 && half > OFFSET * 0.25,
"mid-swing, eased: {half}"
);
let home = run(&mut rig, c, follow_cmd::FORWARD, half, 0.3);
assert!(home.abs() < 1.0e-4, "arrived behind the character: {home}");
// And holding W changes nothing further — the transition is spent, not a standing chase.
let still_home = run(&mut rig, c, follow_cmd::FORWARD, home + 0.4, 1.0);
assert_eq!(
still_home,
home + 0.4,
"a HELD key re-arms nothing: only edges arm"
);
// ── Smart: releasing W (an edge into Stop) cancels rather than arming, so a camera nudged
// while stopping stays nudged.
let mut rig = FollowRig::default();
let held = run(&mut rig, c, follow_cmd::FORWARD, 0.0, 0.1);
let released = run(&mut rig, c, 0, held + OFFSET, 0.5);
assert_eq!(released, held + OFFSET, "Stop is a cancel under Smart");
// ── Always arms on that very same Idle edge — the one row where the two styles differ.
let mut rig = FollowRig::default();
let a = cfg(FollowStyle::Always);
let held = run(&mut rig, a, follow_cmd::FORWARD, 0.0, 0.1);
let returned = run(&mut rig, a, 0, held + OFFSET, 1.0);
assert!(
returned.abs() < 1.0e-4,
"Always returns even from a standstill: {returned}"
);
// ── Never is inert, edge or no edge.
let mut rig = FollowRig::default();
let n = cfg(FollowStyle::Never);
let _ = run(&mut rig, n, 0, OFFSET, 0.1);
assert_eq!(
run(&mut rig, n, follow_cmd::FORWARD, OFFSET, 2.0),
OFFSET,
"Never never arms"
);
// ── The duration floor: a 5° correction is 0.028 s of travel at 180 °/s, and the
// reference's 0.1 s minimum overrides it — so it is NOT finished after 0.05 s.
let mut rig = FollowRig::default();
let small = 5.0_f32.to_radians();
let _ = run(&mut rig, c, 0, small, DT);
let mid = run(&mut rig, c, follow_cmd::FORWARD, small, 0.05);
assert!(
mid.abs() > 1.0e-4,
"the 0.1 s floor is doing the work: {mid}"
);
assert!(run(&mut rig, c, follow_cmd::FORWARD, mid, 0.06).abs() < 1.0e-4);
// ── Track (a taxi, a spline): Smart takes it lazily — 0.4 s of dead time first, then a
// factor-10 return the 2 s ceiling caps.
let mut rig = FollowRig::default();
let _ = run(&mut rig, c, 0, OFFSET, DT);
let delayed = run(&mut rig, c, follow_cmd::TRACK, OFFSET, 0.3);
assert_eq!(delayed, OFFSET, "nothing moves inside the 0.4 s delay");
let crawling = run(&mut rig, c, follow_cmd::TRACK, delayed, 0.6);
assert!(
crawling > OFFSET * 0.5,
"a factor-10 return is a crawl, not a swing: {crawling}"
);
assert!(
run(&mut rig, c, follow_cmd::TRACK, crawling, 2.0).abs() < 1.0e-4,
"and it does arrive, inside the 2 s cap"
);
// ── A held drag freezes the channel outright: the hand owns the camera.
let mut rig = FollowRig::default();
let input = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD,
};
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
// ── …and entering the drag **cancels** what was in flight (`0x50fe30` zeroes the
// descriptors), so the return does not simply resume when the button comes up. Grab the
// camera mid-swing and it stays where the hand left it until the next input edge.
let mut rig = FollowRig::default();
let _ = run(&mut rig, c, 0, OFFSET, DT);
let mid = run(&mut rig, c, follow_cmd::FORWARD, OFFSET, 0.1);
assert!(mid < OFFSET && mid > 0.0, "mid-swing: {mid}");
let dragging = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
};
assert!(rig.advance(&dragging, mid, DT, true).is_none());
// The word is unchanged from the drag frame's, so nothing re-arms on its own…
let parked = {
let mut yaw = mid;
for _ in 0..120 {
let input = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
};
if let Some(y) = rig.advance(&input, yaw, DT, false) {
yaw = y;
}
}
yaw
};
assert_eq!(parked, mid, "the cancelled transition does not resume");
// …and the release's own edge (the mouse bit leaving the word) is what starts a new one.
assert!(
run(&mut rig, c, follow_cmd::FORWARD, parked, 1.0).abs() < 1.0e-4,
"the release edge arms a fresh return"
);
}
/// The state classifier's three vanilla input rules (wow-re `camera-smooth-style.md` §6.2) —
/// the ones a client that keys off character *velocity* cannot reproduce, because they are
/// read off the camera's own command word: right-mouse alone is a `Turn`, a turn key held
/// **under** right-mouse is a `Strafe`, and both mouse buttons together are a `Move`. Plus the
/// priority order, which is what decides the row when several are true at once.
#[test]
fn the_follow_state_reads_the_camera_input_word_not_the_character() {
let state = |command: u32, stopping: bool| {
FollowInput {
cfg: FollowConfig::default(),
face_yaw: 0.0,
command,
}
.state(stopping)
};
assert_eq!(state(0, false), FollowState::Idle);
assert_eq!(state(0, true), FollowState::Stop);
assert_eq!(state(follow_cmd::RIGHT_MOUSE, false), FollowState::Turn);
assert_eq!(
state(follow_cmd::RIGHT_MOUSE | follow_cmd::TURN_LEFT, false),
FollowState::Turn,
"Turn outranks the Strafe its own condition also satisfies"
);
assert_eq!(state(follow_cmd::STRAFE_LEFT, false), FollowState::Strafe);
assert_eq!(
state(follow_cmd::RIGHT_MOUSE | follow_cmd::LEFT_MOUSE, false),
FollowState::Turn,
"both buttons satisfy Move, but Turn outranks it"
);
assert_eq!(state(follow_cmd::LEFT_MOUSE, false), FollowState::Idle);
assert_eq!(state(follow_cmd::AUTORUN, false), FollowState::Move);
assert_eq!(state(follow_cmd::TRACK, false), FollowState::Track);
assert_eq!(
state(follow_cmd::TRACK | follow_cmd::FORWARD, false),
FollowState::Move,
"Move outranks Track"
);
assert_eq!(
state(follow_cmd::FEAR | follow_cmd::FORWARD, false),
FollowState::Fear,
"Fear outranks everything"
);
// …and the tracking style is the selector the moment Track or Fear is merely PRESENT,
// even when a higher-priority state supplies the row.
let mixed = FollowInput {
cfg: FollowConfig {
style: FollowStyle::Never,
tracking_style: FollowStyle::Always,
yaw_speed: FOLLOW_SPEED_DEFAULT,
},
face_yaw: 0.0,
command: follow_cmd::TRACK | follow_cmd::FORWARD,
};
assert_eq!(mixed.style(), FollowStyle::Always);
assert_eq!(mixed.state(false), FollowState::Move);
}
/// The enum is the ENGINE's (0/1/2), not the 1/2/3 the reference's own dropdown writes — and
/// that stray `3` still has to mean Never, because that is what whoever wrote it meant.
#[test]
fn the_follow_style_enum_is_the_engines_and_tolerates_the_dropdowns_stray() {
assert_eq!(FollowStyle::from_cvar(0.0), FollowStyle::Never);
assert_eq!(FollowStyle::from_cvar(1.0), FollowStyle::Smart);
assert_eq!(FollowStyle::from_cvar(2.0), FollowStyle::Always);
assert_eq!(FollowStyle::from_cvar(3.0), FollowStyle::Never);
// Off the ladder entirely: the registrar default, not a dead camera.
assert_eq!(FollowStyle::from_cvar(-1.0), FollowStyle::Smart);
assert_eq!(FollowStyle::from_cvar(9.0), FollowStyle::Smart);
for style in [FollowStyle::Never, FollowStyle::Smart, FollowStyle::Always] {
assert_eq!(
FollowStyle::from_cvar(style.cvar().parse::<f32>().unwrap()),
style,
"the string round-trips"
);
}
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
}
/// The second arm: between 200 and 800 world the travel gate applies, per axis and independently
+25 -2
View File
@@ -105,7 +105,7 @@ pub(super) fn release_post_snap_hold(
mut player: ResMut<Player>,
progress: Option<Res<WorldLoadProgress>>,
time: Res<Time>,
mut last_counters: Local<Option<[usize; 5]>>,
mut last_counters: Local<Option<[usize; 6]>>,
net_cmds: Option<Res<crate::net::NetCommands>>,
) {
let Some(p) = progress else { return };
@@ -127,7 +127,8 @@ pub(super) fn release_post_snap_hold(
player.world_stale = false;
}
// Did the stream move since last frame? Any counter changing — a tile spawned, a placement
// up, a collider queued or attached — is the destination still arriving. Tracked every frame
// up, a collider queued or attached, a retained region baked — is the destination still
// arriving. Tracked every frame
// (not just while settling) so the first settling frame compares against a real baseline.
let counters = [
p.ready,
@@ -135,6 +136,7 @@ pub(super) fn release_post_snap_hold(
p.colliders_pending,
p.merge_pending,
p.placements_pending,
p.gx_pending,
];
let progressed = last_counters.replace(counters) != Some(counters);
if !player.settling {
@@ -318,6 +320,27 @@ mod tests {
assert!(!settling(&mut app), "presentable world, hold still on");
}
/// Decision 1498: geometry that has SPAWNED but not yet baked is a hole in the world exactly
/// like an unattached collider, so it holds the post-snap freeze too. The retained pass took
/// the static world off the entity path (1429), and from then on "the placement spawned" and
/// "the building is on screen" stopped being the same fact — the hold, like the reveal, keys
/// on the second.
#[test]
fn an_unbaked_region_holds_the_settle() {
let mut app = app();
app.world_mut().resource_mut::<Player>().world_stale = false;
step(&mut app, 1.0, |p| {
p.total = 2000;
p.ready = 2000;
p.colliders_pending = 0;
p.scene_ready = true;
p.gx_pending = 4;
});
assert!(settling(&mut app), "spawned but undrawn is not presentable");
step(&mut app, 0.1, |p| p.gx_pending = 0);
assert!(!settling(&mut app), "baked — the world is really there");
}
/// B263 round 3 (decision 1336): residency published for ANOTHER tile never releases the hold
/// and never clears the stale flag — the live defect was the focus publish racing the teleport
/// snap, so on the snap frame the streamer described the DEPARTURE city as fully resident and
+33
View File
@@ -274,6 +274,39 @@ pub(crate) struct UiQuads {
pub dirty: bool,
}
/// The **append lane's own z bands** — the world-anchored overlays' slice of the same `z_key`
/// total order the scripted UI packs its `(stratum, level, layer, …)` tuple into
/// ([`benilla_ui::order::ZKey`]).
///
/// The lane's three producers are three *different* client systems that all draw over the world,
/// and their relative order is a fidelity fact each one owns (the floating combat numbers are a
/// world-scene draw under all UI; the plates and the bubbles are frames). What is NOT a fidelity
/// fact is the arithmetic that keeps them apart — and while each producer picked its own small
/// integer, that arithmetic was three private conventions with nothing naming the shared law:
/// combat text at 0, the bubble's four pieces at 0..=3 *on top of it*, the plates at 4..=8. The
/// bands below make the split explicit and, more to the point, give the middle band **room** —
/// a chat bubble is one frame per speaker with its own frame level (decision 1504), which a
/// four-key allocation cannot express. Everything here stays far below the scripted UI's keys
/// (a `ZKey` region carries its is-region bit at `1 << 20`), so the whole lane still paints
/// under the player UI.
pub(crate) mod overlay_z {
/// Floating combat/XP/honor numbers — the client draws them in the world scene, beneath
/// every UI frame ([`crate::combat_text`]).
pub(crate) const WORLD_TEXT: u64 = 0;
/// The chat bubbles' band ([`crate::chat_bubble`]): one **frame level** per live bubble,
/// [`BUBBLE_STRIDE`] keys wide, ordered farthest-camera-distance first.
pub(crate) const BUBBLE: u64 = 1 << 8;
/// Keys per bubble level — the frame's own four pieces (bg, edge, tail, text).
pub(crate) const BUBBLE_STRIDE: u64 = 4;
/// The V-plates ([`crate::vplates`]), above every bubble. Same-unit overlap cannot happen
/// (the plate/bubble mutual exclusion), so this only orders cross-unit stacking.
pub(crate) const VPLATE: u64 = 1 << 16;
/// The highest bubble level the band holds before it would reach [`VPLATE`]. Far past any
/// real population (a bubble needs a speaker within 20 yd), so the clamp is a guard rail,
/// not a policy.
pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1;
}
/// Clear the append lane at the top of the [`UiQuadAppend`] window — every appender re-emits its
/// frame's quads after this; [`rebuild_ui_mesh`] then diffs the lane to decide if anything changed.
fn clear_ui_overlays(mut quads: ResMut<UiQuads>) {
+5
View File
@@ -853,6 +853,11 @@ mod shipped_xml_tests;
#[cfg(test)]
mod bagnon_render_tests;
/// Bug B267 end to end: a hunter's Quiver publishes its global functions (see the file header for
/// the three walls that stopped it).
#[cfg(test)]
mod quiver_tests;
/// The UI's per-world-entry lifecycle: teardown at the character screen, a genuine second load at
/// the next login (decision 1290).
#[cfg(test)]
@@ -2803,16 +2803,16 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Camera Following Style** (decision 1493) — 1.12's `cameraSmoothStyle`, worn as a Controls-page
/// dropdown, and the setting that decides whether the camera swings back behind the character at
/// all. Three things pinned: the reference's own values *and* their order (Smart `"1"`, Always
/// `"2"`, Never `"3"` — Never is **3**, not 0, which is exactly what a tidy-up would get wrong);
/// the registered default landing on Smart, where benilla behaved as Never for its whole life
/// before this row; and the plate following the SELECTION rather than the row — the reference's own
/// dropdown repaints its tooltip per entry (`OPTION_TOOLTIP_CAMERA1/2/3`) and this is the first row
/// here whose description is not a property of the row alone.
/// **Camera Following Style** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, worn as a
/// Controls-page dropdown, and the setting that decides whether the camera returns to behind the
/// character at all. What is pinned here is the trap: the reference's own dropdown writes `1/2/3`,
/// but the ENGINE's tables are indexed `0 = Never · 1 = Smart · 2 = Always`, and `3` is not a style
/// — the validator accepts it while the terrain-tilt consumer indexes off the end of its table
/// (wow-re `camera-smooth-style.md` §2/§4). So our entries carry the engine's numbers in the
/// reference's display order, a stray `3` still reads as Never rather than as the numerically
/// nearest "Always", and the plate follows the SELECTION the way that dropdown's own does.
#[test]
fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate() {
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowControls:Click()")
@@ -2843,8 +2843,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
"reading the table on select must not write it back"
);
// The list is the reference dropdown's, entry for entry: Smart, Always, Never — with the
// stored value checked.
// The list is the reference dropdown's, entry for entry and in its order.
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
assert_eq!(
@@ -2863,12 +2862,12 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
.unwrap());
// Never stores "3" — the value the reference writes, not the 0 a fresh enum would invent —
// Never stores "0" — the engine's own index, not the "3" the reference's dropdown writes —
// and the row's plate becomes Never's own description.
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "3".to_string())]
vec![("cameraSmoothStyle".to_string(), "0".to_string())]
);
assert_eq!(
s.eval::<String>(
@@ -2884,8 +2883,7 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
"the plate follows the selection, like the reference dropdown's own"
);
// Always is the middle entry and stores "2" (the pairing that goes wrong if the order is read
// off the alphabet instead of off the reference).
// Always is the middle entry and stores "2".
s.run("OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
s.run("DropDownList1Button2:Click()").unwrap();
@@ -2894,6 +2892,24 @@ fn the_camera_following_style_dropdown_carries_the_reference_values_and_plate()
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
);
// A config written by a REAL 1.12 client says "3" for Never. It must not display as the
// numerically nearest stop — which is Always, the opposite of what it means.
s.set_cvar_host("cameraSmoothStyle", "3");
s.run("OptionsFrameCategoryListRowAudio:Click(); OptionsFrameCategoryListRowControls:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Never",
"the reference dropdown's own stray value still means Never"
);
assert!(
s.take_cvar_changes().is_empty(),
"displaying a stray value must not write it back"
);
// Defaults walks it back to Smart.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(
@@ -0,0 +1,272 @@
//! **"`Quiver.CastPetAction` is nil when the addon's own macro runs"** — bug B267, reproduced end
//! to end and then closed.
//!
//! ## Why this file exists
//!
//! The report is a `/run Quiver.CastPetAction("Furious Howl")` macro raising
//! `attempt to call field 'CastPetAction' (a nil value)`. The field is published on the addon's
//! **last** line of `VARIABLES_LOADED`:
//!
//! ```lua
//! if event == "VARIABLES_LOADED" then
//! LoadLocale() Migrations() savedVariablesRestore()
//! initSlashCommandsAndModules() -- builds the whole config UI, HUNTERS ONLY
//! RegisterGlobalFunctions() -- <- Quiver.CastPetAction = … lives here
//! ```
//!
//! so anything that raises in that handler takes every global function with it. Three separate
//! walls did, one behind the other, and each is a real gap of ours:
//!
//! | | what it does | who found it |
//! |---|---|---|
//! | the Region method map was two implementations, not one | `Api._Height = WorldFrame.GetHeight` applied to a Texture raised `stale or invalid frame handle` | this bug |
//! | `Button:SetFontString` missing | every dropdown option row died on its first line | behind wall 1 |
//! | `Frame:SetMinResize`/`SetMaxResize` missing | `SideEffectMakeMoveable` died on every module frame | behind wall 2 |
//!
//! **The addon survey scored Quiver `loaded, session=ok, probe=ok` through all three.** It seats a
//! WARRIOR, and every one of these is behind `if cl == "HUNTER"` — the same blind spot shape as
//! Bagnon's (`ui_script::bagnon_render_tests`), one axis over: there the columns asked "did it
//! raise" and never "did it draw"; here they ask both, of a session the addon declines to run in.
//! So the fixture below seats a **hunter**, and that is the load-bearing part of it.
//!
//! Nothing from the corpus is committed, and the test skips cleanly on a machine without it — the
//! `ui_chat::ace_gate_tests` rule.
use std::path::{Path, PathBuf};
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
/// a machine that wants this test builds it into the corpus once.
fn quiver_root() -> Option<PathBuf> {
corpus_candidates()
.into_iter()
.find(|c| c.join("Quiver").join("Quiver.toc").is_file())
}
macro_rules! quiver_or_skip {
() => {
match quiver_root() {
Some(root) => root,
None => {
eprintln!(
"skipping: no Quiver in the addon corpus — looked in {:?} \
(set $BENILLA_ADDON_CORPUS; the folder needs Quiver.toc + Quiver.bundle.lua)",
corpus_candidates()
);
return;
}
}
};
}
fn read_toc(root: &Path, name: &str) -> Toc {
let path = root.join(name).join(format!("{name}.toc"));
Toc::parse(&benilla_ui::source::decode(
&std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())),
))
}
/// One addon's `.toc` files through the same two arms the real loader uses (decision 1186).
fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
let toc = read_toc(root, name);
let provider = |req: &str| -> Option<Vec<u8>> { std::fs::read(root.join(req)).ok() };
let mut errors = Vec::new();
for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file);
let Ok(bytes) = std::fs::read(root.join(&path)) else {
errors.push(format!("{file}: not found"));
continue;
};
if file.to_ascii_lowercase().ends_with(".lua") {
if let Err(e) =
script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file))
{
errors.push(format!("{file}: {e}"));
}
continue;
}
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(doc) => {
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
}
Err(e) => errors.push(format!("{file}: {e}")),
}
}
errors
}
/// A VM shaped like the reporter's session: our whole interface, and a **hunter** at the keyboard.
///
/// The class is the point (see the module doc). Everything else is the addon-survey seat's own
/// minimum — a named player on a named realm, with a faction group, because a character in a real
/// session always has all three and a nil one is a failure mode no player can produce.
fn seat_a_hunter(root: &Path) -> UiScript {
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
s.set_realm_name("Harness");
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Harness".into()),
health: 100,
max_health: 100,
level: 60,
power_type: 0,
power: 100,
max_power: 100,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
faction_group: Some("Alliance".into()),
..Default::default()
}),
);
// A HUNTER'S SPELLBOOK. Quiver's `Api.Spell.FindSpellIndex` opens
// `GetSpellTabInfo(GetNumSpellTabs())` and adds `offset + numSpells`, so an EMPTY book is
// `attempt to perform arithmetic on local 'tabOffset'` every tick — the reference answers nil
// there too, so that is the addon's own defect on a spell-less character and not a gap of
// ours. Seating a book is what lets this test TICK, which is where the module OnUpdates live.
// Two tabs and four spells is the addon-survey fixture's own shape, with a hunter's names.
{
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
let slot = |spell_id: u32, name: &str, rank: Option<&str>| SpellSlotView {
spell_id,
name: name.to_string(),
rank: rank.map(str::to_string),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
..Default::default()
};
s.set_spellbook(SpellBookState {
tabs: vec![
SpellTabView {
name: "General".into(),
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
offset: 0,
num_spells: 2,
},
SpellTabView {
name: "Marksmanship".into(),
texture: Some("Interface\\Icons\\Ability_Marksmanship".into()),
offset: 2,
num_spells: 2,
},
],
slots: vec![
slot(6603, "Attack", None),
slot(75, "Auto Shot", None),
slot(2973, "Raptor Strike", Some("Rank 1")),
slot(1978, "Serpent Sting", Some("Rank 1")),
],
});
}
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
let failures = super::load_default_ui(&s);
assert!(
failures.is_empty(),
"our own FrameXML failed to load: {failures:#?}"
);
s
}
/// **The reported symptom.** Load Quiver into a hunter's session, drive the session start the
/// client drives, and ask the question the macro asked.
///
/// The assertion is deliberately the addon's own field and not "no errors": B267 was filed as a
/// nil field, and a future wall inside `initSlashCommandsAndModules` would take this field out
/// again even if it left a different error behind.
#[test]
fn quiver_publishes_its_global_functions_for_a_hunter() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
let load_errors = load_addon_files(&s, &root, "Quiver");
assert!(load_errors.is_empty(), "Quiver's files: {load_errors:#?}");
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
// The macro the report ran: `/run Quiver.CastPetAction("Furious Howl"); …`
assert_eq!(
s.eval::<String>("return type(Quiver.CastPetAction)")
.unwrap(),
"function",
"B267: the addon's own field is nil because its VARIABLES_LOADED handler died \
before publishing it — errors so far: {:#?}",
s.errors()
);
// The whole of `RegisterGlobalFunctions`, not just the one the report named — they publish
// together, so any of them missing means the same handler died at the same place.
for name in [
"CastNoClip",
"CastPetAction",
"FdPrepareTrap",
"GetSecondsRemainingReload",
"GetSecondsRemainingShoot",
"PredMidShot",
"TrinketSwap1",
"TrinketSwap2",
] {
assert_eq!(
s.eval::<String>(&format!("return type(Quiver.{name})"))
.unwrap(),
"function",
"Quiver.{name} was never published"
);
}
}
/// The session start itself raises **nothing** — the three walls, stated as the thing they were.
///
/// Separate from the field assertion above because it fails differently and more usefully: this is
/// the test that names a *new* wall the moment one appears, instead of reporting a nil field.
#[test]
fn quiver_survives_a_hunter_session_start_without_raising() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
// …and then a second of frames, because Quiver's modules are OnUpdate-driven (the auto-shot
// timer, the range indicator, the aspect tracker) and a handler that only raises once it is
// *running* is invisible to the event burst alone.
for _ in 0..10 {
s.tick(0.1);
}
let raised = s.take_errors();
assert!(
raised.is_empty(),
"Quiver raised at session start: {raised:#?}"
);
}
+9 -8
View File
@@ -79,7 +79,7 @@ use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::names::NameCache;
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::target::{ring_reaction, Factions, Hovered, Selection, TargetUpdate};
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
@@ -172,13 +172,14 @@ const LIT_BOOST: f32 = 255.0 / 215.0;
/// The plate quads' z keys — back→front: bar fill, then the border OVER it (the border art's
/// rounded inner bevels cap the fill's square ends and crop the gradient's soft edges — the
/// reference look, director-pinned from a ref crop 2026-07-07; corrects the §7 transcription's
/// border-first order), then texts → skull; all above the floating combat text (z 0, a
/// world-scene draw) and far below every scripted-UI packed key.
const Z_FILL: u64 = 4;
const Z_BORDER: u64 = 5;
const Z_TEXT: u64 = 6;
const Z_RAID: u64 = 7;
const Z_SKULL: u64 = 8;
/// border-first order), then texts → skull; all above the floating combat text and the chat
/// bubbles (the append lane's lower bands, [`crate::ui_pass::overlay_z`]) and far below every
/// scripted-UI packed key.
const Z_FILL: u64 = overlay_z::VPLATE;
const Z_BORDER: u64 = overlay_z::VPLATE + 1;
const Z_TEXT: u64 = overlay_z::VPLATE + 2;
const Z_RAID: u64 = overlay_z::VPLATE + 3;
const Z_SKULL: u64 = overlay_z::VPLATE + 4;
/// The bar-fill palette — the byte-confirmed dwords (`0xcf60d0/e8/c8/dc`): pure
/// red/blue/yellow/green (NOT the ring's pale player-blue). Client-space sRGB, the [`UiQuads`]
+10 -1
View File
@@ -256,7 +256,16 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
/// alternative was ordering a game system against an engine system, which crosses this same line
/// *and* publishes a schedule point to do it.
const CEILING: usize = 159;
/// And 159 → 160: `WorldLoadProgress::gx_pending`, a PUBLISH beside the two residency terms
/// already through this door (`colliders_pending`, `merge_pending`). The retained pass (1429)
/// moved the static world off the entity path, so a placement that has SPAWNED still draws
/// nothing until its region bakes — and the game reads residency for two decisions the engine
/// does not own: when the loading cover may lift, and when the post-snap physics hold may
/// release (decision 0737's split). Both were answering "is the world there" with a fact that
/// had stopped meaning it (decision 1498). The alternative was the game reaching into
/// `StaticGx` itself, which is a whole engine subsystem through the door instead of one
/// `usize` on the residency struct that exists to be read from outside.
const CEILING: usize = 160;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+32
View File
@@ -296,6 +296,38 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
)?;
// SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label
// (`0x780a60`; the pointer it writes is the Button's `+0x338`, the field
// `GetFontString`/`SetText`/`GetTextWidth` all read — wow-re `widget-api-batch-benilla.md` Q8).
//
// The idiom it exists for: build the label yourself so you can style and place it, then hand it
// to the button so `SetText` and the per-state font machinery drive it.
//
// local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
// btn:SetFontString(fs)
//
// Quiver's `Component/Select.wow.lua` builds every dropdown option row that way, and with the
// method nil the row died on its first line — the second of B267's three walls.
//
// Adoption only: no region is created, and the argument keeps its own parent and its own
// anchors (callers anchor it after the call as often as before). A non-region argument leaves
// the button unchanged rather than raising — the reference marshals it and writes a pointer,
// and what it does with a bad one is not byte-read.
m.set(
"SetFontString",
lua.create_function(|lua, (this, fs): (Table, Value)| {
let Value::Table(fs) = fs else { return Ok(()) };
let Ok(rh) = super::region::region_handle_of(lua, &fs) else {
return Ok(());
};
with_button(lua, &this, |bs| bs.text = Some(rh))?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The label is a measured region, and its new role feeds the button's text extents.
model.touch_measure(rh);
Ok(())
})?,
)?;
// GetTextWidth / GetTextHeight — the Button's OWN text-extent readers (`0x782290` / `0x782390`,
// wow-re `system/ui/scratch/item9-firing34-merge.md` l.36 and the Button method carve in
// `widget-api-batch-benilla.md` Q8, which lists both present on Button and `GetStringWidth`
+3
View File
@@ -99,6 +99,9 @@ mod pvp;
mod quest;
mod quest_log;
mod region;
/// The 19 Region-map methods, shared by frames and regions the way the reference shares them
/// (decision 1501) — its header is the byte-verified chain and the bug that found the split.
mod region_map;
mod reputation;
mod saved;
mod scrollframe;
+3
View File
@@ -224,6 +224,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::statusbar::install(lua)?;
super::button::install(lua)?;
super::editbox::install(lua)?;
// AFTER every table above exists: collapse the 19 Region-map names to one shared
// implementation each, so a method pulled off a frame works on a texture (decision 1501).
super::region_map::install(lua)?;
// Shared frame metatable: __index is a Rust dispatcher over the frame method table (RF-0023),
// checking the frame's *kind-specific* method table first. Per-kind resolution matters beyond
@@ -78,6 +78,21 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
))
})?,
)?;
// GetNumPoints() → how many anchors this frame carries. On the Region map (`0x87c9b8`), so
// every widget answers it — the region twin shipped first and noted this side was missing;
// collapsing the map to one implementation each (decision 1501) is what made the gap fatal
// rather than merely absent, and this is the arm it was missing.
m.set(
"GetNumPoints",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.layout_inputs
.get(&h)
.map_or(0, |i| i.anchors.len() as i64))
})?,
)?;
// SetAllPoints([relativeTo]) — pin TOPLEFT+BOTTOMRIGHT to the target (default: the parent),
// the XML `setAllPoints="true"` behavior as a method (rf24 `0x767800`'s SetAllPoints path).
m.set(
+136 -15
View File
@@ -78,7 +78,7 @@
//! for both halves in the reference too — already covers the stop side of a resize that does
//! not exist yet.
use mlua::{Lua, Table};
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::Model;
use crate::widget::FrameHandle;
@@ -155,6 +155,93 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"IsResizable",
lua.create_function(|lua, this: Table| get_flag(lua, &this, Flag::Resizable))?,
)?;
// ── The resize-bounds quad — `Set/GetMinResize`, `Set/GetMaxResize` ────────────────────────
//
// Frame method table `0x878ec0`: `GetMinResize 0x775f20` · `SetMinResize 0x776020` ·
// `GetMaxResize 0x7761a0` · `SetMaxResize 0x7762a0`. The setters' `Usage:` strings are in the
// image verbatim — `0x8797b8 "Usage: %s:SetMinResize(minWidth, minHeight)"` and
// `0x8797e4 "Usage: %s:SetMaxResize(maxWidth, maxHeight)"` — so a bad argument RAISES, and
// that is what is reproduced here.
//
// **Who asks.** They are the second and third lines of every resizable-window kit, right after
// `SetResizable(true)`; Quiver's `SideEffectMakeMoveable` calls `SetMinResize` on every module
// frame it builds, and with the method nil its `OnEnable` died before the addon's
// `VARIABLES_LOADED` handler could publish anything (bug B267's third wall).
//
// **Two halves are out with wow-re and fold into ONE place** ([`clamp_resize`]): whether the
// client's "unbounded" is a `0` sentinel the getters hand back (ours answers `nil`), and
// whether the clamp binds only the drag pump (what we do) or every size write. Neither changes
// the surface below.
for (name, upper) in [("MinResize", false), ("MaxResize", true)] {
m.set(
format!("Set{name}"),
lua.create_function(move |lua, args: MultiValue| {
let mut it = args.into_iter();
let this = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a frame")),
};
let h = frame_handle_of(lua, &this)?;
// The pair is required. `lua_isnumber` accepts a numeric STRING too, which is why
// this reads the values through the same coercion the rest of the surface uses
// rather than demanding `Value::Number`.
let pair = (it.next(), it.next());
let num = |v: &Option<Value>| match v {
Some(Value::Number(n)) => Some(*n as f32),
Some(Value::Integer(i)) => Some(*i as f32),
Some(Value::String(s)) => s.to_str().ok()?.parse::<f32>().ok(),
_ => None,
};
let (Some(w), Some(hgt)) = (num(&pair.0), num(&pair.1)) else {
let model = lua.app_data_ref::<Model>().expect("model");
let who = model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
let verb = if upper {
"SetMaxResize"
} else {
"SetMinResize"
};
let (a, b) = if upper {
("maxWidth", "maxHeight")
} else {
("minWidth", "minHeight")
};
return Err(mlua::Error::runtime(format!(
"Usage: {who}:{verb}({a}, {b})"
)));
};
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(f) = model.arena.frame_mut(h) {
if upper {
f.max_resize = Some((w, hgt));
} else {
f.min_resize = Some((w, hgt));
}
}
Ok(())
})?,
)?;
m.set(
format!("Get{name}"),
lua.create_function(move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let bound =
model
.arena
.frame(h)
.and_then(|f| if upper { f.max_resize } else { f.min_resize });
Ok(match bound {
Some((w, hgt)) => (Value::Number(f64::from(w)), Value::Number(f64::from(hgt))),
None => (Value::Nil, Value::Nil),
})
})?,
)?;
}
// SetUserPlaced(flag) / IsUserPlaced() — bit 0x1000 (`0x776a50`/`0x776b40`), the client's "the
// user placed this frame; persist its position across sessions" bit, and the one setter of the
// three that is GUARDED: `776adb: test ah,0x3` refuses unless the frame is movable OR
@@ -381,15 +468,31 @@ pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
true
}
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
/// crosses no boundary at all, so a move parked behind that return would only advance when the
/// cursor happened to leave the frame).
/// Apply a frame's `SetMinResize`/`SetMaxResize` bounds to a proposed size.
///
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
/// writing to a dead handle.
/// **The one place the two open halves of the resize-bounds RE fold into.** Today: an unset bound
/// is unbounded, the floor with no `SetMinResize` is the `1.0` this function inherited from the
/// drag pump it was extracted from, and the *only* caller is [`advance_size`] — so a programmatic
/// `SetWidth` past a bound is currently not clamped. wow-re is deriving whether the client clamps
/// there too, and what its no-bound floor really is; both answers land here and nowhere else.
///
/// A `min` above a `max` resolves in `min`'s favour (the clamp order below), which is the only
/// self-consistent reading of a contradiction and is also out with wow-re.
fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) {
let (min, max) = model
.arena
.frame(h)
.map_or((None, None), |f| (f.min_resize, f.max_resize));
let mut w = w;
let mut hgt = hgt;
if let Some((mw, mh)) = max {
w = w.min(mw);
hgt = hgt.min(mh);
}
let (fw, fh) = min.unwrap_or((1.0, 1.0));
(w.max(fw), hgt.max(fh))
}
/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others.
///
/// The mirror of [`advance_move`] — same sample-and-recentre, same dead-frame bail, same
@@ -409,9 +512,6 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
}
let inv = 1.0 / eff_scale(model, sz.frame);
let (dx, dy) = (dx * inv, dy * inv);
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
return;
};
// Width grows when the RIGHT grip goes right, or the LEFT grip goes left. Height likewise with
// y-up: the TOP grip going up grows it, the BOTTOM grip going down grows it.
let dw = if sz.right {
@@ -428,9 +528,21 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
} else {
0.0
};
let (w0, h0) = (input.width, input.height);
input.width = (input.width + dw).max(1.0);
input.height = (input.height + dh).max(1.0);
let Some((w0, h0)) = model
.layout_inputs
.get(&sz.frame)
.map(|i| (i.width, i.height))
else {
return;
};
// The frame's own `SetMinResize`/`SetMaxResize` bounds — read before the mutable borrow, and
// the only place they bind today (see `clamp_resize`).
let (new_w, new_h) = clamp_resize(model, sz.frame, w0 + dw, h0 + dh);
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
return;
};
input.width = new_w;
input.height = new_h;
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
// or the resize would push the right edge instead. Only the gripped axis shifts.
@@ -460,6 +572,15 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
/// Pump an in-flight move to the cursor at `pos` — the engine half of `0x7655b0`, run from
/// [`crate::script::UiScript::mouse_move`] beside [`crate::script::cursor::maybe_start_drag`] and,
/// like it, BEFORE the hover-boundary early return (a frame dragged around underneath the cursor
/// crosses no boundary at all, so a move parked behind that return would only advance when the
/// cursor happened to leave the frame).
///
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
/// writing to a dead handle.
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
let Some(mv) = model.moving else { return };
if model.arena.frame(mv.frame).is_none() {
+140
View File
@@ -0,0 +1,140 @@
//! **The Region method map, reached by frames and regions alike** — the 19 names of the client's
//! `0x87c9b8` table, each ONE callable that works on a Frame, a Texture or a FontString.
//!
//! ## The fact this exists for (byte-verified in `WoW.exe`, decision 1501)
//!
//! 1.12.1's widget inheritance is not a Lua metatable chain: every class owns a flat
//! `{name, lua_CFunction}` `.data` table, and its lookup probes that table and, on a miss,
//! **tail-calls exactly one base class's lookup** (wow-re
//! `system/ui/scratch/widget-api-batch-benilla.md`):
//!
//! ```text
//! Region 0x87c9b8 (19) lookup 0x7a2ea0 → TERMINAL (root)
//! ├─ Frame 0x878ec0 (68) lookup 0x778590 → 0x7a2ea0
//! ├─ Texture 0x87c128 (22) lookup 0x79c620 → 0x7a2ea0
//! └─ FontString 0x87c1d8 (32) lookup 0x79ee20 → 0x7a2ea0
//! ```
//!
//! Parsing the two tables out of the image directly, **`Frame ∩ Region = ∅`**: not one of the 19
//! is re-registered by Frame, so every Frame-derived class reaches the *same* function a Texture
//! does. `WorldFrame.GetHeight` **is** `0x7a2030`, byte-for-byte the function
//! `someTexture:GetHeight()` resolves to.
//!
//! That is why this is not a curiosity. The idiom
//!
//! ```lua
//! local _Height = WorldFrame.GetHeight -- pull the method off ANY widget, once
//! …
//! local h = _Height(someTexture) -- and apply it to ANY other
//! ```
//!
//! is ordinary, correct 1.12 Lua. Quiver's `Api/Index.wow.lua` is built on it
//! (`_Height = WorldFrame.GetHeight`, `_Width = WorldFrame.GetWidth`), and benilla raised
//! `stale or invalid frame handle` on it, from inside the addon's `VARIABLES_LOADED` handler,
//! **before** the handler's last line got to publish `Quiver.CastPetAction` — which is the whole
//! of bug B267. One split method surface, one nil field, one dead addon.
//!
//! ## Why it is a bridge rather than a merge
//!
//! Our two implementations of each name are not redundant: a frame's size reads `layout_inputs`
//! and divides its edges by `GetEffectiveScale`, a region's reads `region_data` and does not; a
//! frame's `GetParent` can answer nil, a region's never can; a region's `GetPoint` resolves a
//! `relativeTo` that may be a sibling *region*. Those differences are correct, and they are the
//! same shape the reference has: `Region:GetHeight 0x7a2030` reads the receiver's layout
//! sub-object and calls `[vtable+0x20]`, which `CSimpleFontString` **overrides**. One name, one
//! entry point, per-kind behaviour behind it.
//!
//! So each of the 19 becomes one function that resolves the receiver *first* and then hands the
//! whole argument list to the arm that owns that kind — and that single function is written into
//! every method table the reference's chain would reach: the frame table, the region table, the
//! Texture and FontString leaf tables, and the title region's narrower copy. Identity holds the
//! way the binary's does — `WorldFrame.GetHeight == someTexture.GetHeight` is true here too.
//!
//! **Exactly the 19, and no more.** `Show`/`Hide`/`IsShown`/`IsVisible`/`SetAlpha`/`GetAlpha` look
//! like they belong and do not: Frame and Texture each register their *own*, at different
//! addresses (`texture-fontstring-method-split.md` §3), so `WorldFrame.Show(someTexture)` fails on
//! the real client and must keep failing here. `SetSize` is in neither table — it is 1.12-absent
//! and ours, so it stays where it is. The map is the unit.
use mlua::{Function, Lua, MultiValue, Table, Value};
use super::object::decode_id;
use super::{
Model, REGION_MAP_METHODS, REG_FONTSTRING_METHODS, REG_FRAME_METHODS, REG_REGION_METHODS,
REG_TEXTURE_METHODS, REG_TITLE_METHODS,
};
/// Which side of the object model a wrapper's `T[0]` id names. Ids come from one counter
/// ([`Model::next_id`]), so a region id can never be mistaken for a frame's.
enum Side {
Frame,
Region,
}
/// `Err` when the table is not a widget wrapper at all (that error names the real problem and is
/// worth keeping); `Ok(None)` when it *is* one whose widget no longer exists.
fn side_of(lua: &Lua, this: &Table) -> mlua::Result<Option<Side>> {
let id = decode_id(this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.id_to_frame.contains_key(&id) {
Some(Side::Frame)
} else if model.id_to_region.contains_key(&id) {
Some(Side::Region)
} else {
None
})
}
/// Replace each of the 19 Region-map entries in every table the chain reaches with one shared
/// function that dispatches on the receiver.
///
/// Runs **after** `region::install` (which is what builds the region, leaf and title tables) and
/// after `install_frame_methods`; every table it names is already populated by then.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let frame: Table = lua.named_registry_value(REG_FRAME_METHODS)?;
let region: Table = lua.named_registry_value(REG_REGION_METHODS)?;
let leaves: Vec<Table> = [
REG_TEXTURE_METHODS,
REG_FONTSTRING_METHODS,
REG_TITLE_METHODS,
]
.into_iter()
.map(|k| lua.named_registry_value::<Table>(k))
.collect::<mlua::Result<_>>()?;
for name in REGION_MAP_METHODS {
// Both arms must already exist. A missing one is not a thing to paper over with a
// one-sided shared function: the reference gives every widget all 19, so a gap on either
// side is a hole in the surface and says so here rather than at some addon's call site.
// (`GetNumPoints` was exactly that hole on the frame side until this module went in.)
let on_frame: Function = frame.get(name).map_err(|_| {
mlua::Error::runtime(format!("Region map: the FRAME table has no {name}"))
})?;
let on_region: Function = region.get(name).map_err(|_| {
mlua::Error::runtime(format!("Region map: the REGION table has no {name}"))
})?;
let shared = lua.create_function(move |lua, args: MultiValue| {
// The receiver is argument 1 on every one of the 19 — a method call always passes it,
// and a pulled-off `Api._Height(x)` call passes it as the only argument.
let Some(Value::Table(this)) = args.iter().next().cloned() else {
return Err(mlua::Error::runtime(
"expected a frame or region as the first argument",
));
};
match side_of(lua, &this)? {
Some(Side::Frame) => on_frame.call::<MultiValue>(args),
Some(Side::Region) => on_region.call::<MultiValue>(args),
// A wrapper whose widget is gone. Deliberately ONE message for both sides — at
// this point the receiver's kind is exactly what could not be established, and
// each side's old wording claimed it.
None => Err(mlua::Error::runtime("stale or invalid widget handle")),
}
})?;
frame.set(name, shared.clone())?;
region.set(name, shared.clone())?;
for leaf in &leaves {
leaf.set(name, shared.clone())?;
}
}
Ok(())
}
@@ -25,6 +25,7 @@ mod minimap;
mod movable;
mod object_model;
mod reference_surface;
mod region_map;
mod regions;
mod scrollframe;
mod setparent;
@@ -0,0 +1,173 @@
//! **The Region map is ONE function per name, reached by frames and regions alike** — the
//! [`crate::script::region_map`] law, and the regression guard for bug B267.
//!
//! Its header carries the byte evidence (`Frame ∩ Region = ∅`, parsed out of the two `.data`
//! method tables). What is pinned here is the two things a reader of the code could not otherwise
//! check: that the sharing is real *by identity*, and that it stops exactly at the 19.
//!
//! The reported symptom this file exists for: Quiver's `Api/Index.wow.lua` opens
//! `_Height = WorldFrame.GetHeight`, applies it to a Texture and a FontString, and benilla raised
//! `stale or invalid frame handle` — inside the addon's `VARIABLES_LOADED` handler, four lines
//! before it published `Quiver.CastPetAction`. The addon's own field was nil at macro time because
//! the handler never finished, which is what got filed.
use crate::script::UiScript;
/// A VM with one frame, one texture and one fontstring, each reachable by a global name.
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
Host = CreateFrame("Frame", "Host")
Host:SetWidth(200) Host:SetHeight(120)
Host:SetPoint("CENTER", nil, "CENTER", 0, 0)
Tex = Host:CreateTexture("HostTex", "ARTWORK")
Tex:SetWidth(64) Tex:SetHeight(32)
Tex:SetPoint("TOPLEFT", Host, "TOPLEFT", 0, 0)
Str = Host:CreateFontString("HostStr", "OVERLAY")
Str:SetWidth(50) Str:SetHeight(10)
Str:SetPoint("BOTTOMRIGHT", Host, "BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
s
}
/// Every one of the 19 is the **same Lua value** on a Frame, a Texture and a FontString.
///
/// Not a stand-in for the behaviour tests below: identity is what makes the pulled-off-method
/// idiom work *for a method the test author never thought of*. In the client it holds because
/// Frame's table does not carry any of the 19 and its lookup tail-calls Region's; here it holds
/// because one function is written into every table that chain reaches.
#[test]
fn the_region_map_is_one_function_on_every_widget() {
let s = vm();
for m in crate::script::REGION_MAP_METHODS {
let same: bool = s
.eval(&format!(
"return Host.{m} == Tex.{m} and Tex.{m} == Str.{m} and Host.{m} ~= nil"
))
.unwrap();
assert!(
same,
"{m} must be ONE function on frame, texture and string"
);
}
}
/// **The bug, reduced to its four lines.** A method pulled off a frame and applied to a region.
#[test]
fn a_method_pulled_off_a_frame_works_on_a_texture_and_a_fontstring() {
let s = vm();
// Quiver's own shape: `Api._Height = WorldFrame.GetHeight`, applied to `r.Icon` (a Texture)
// and `r.Label` (a FontString), from a table built at file scope.
let (th, sh): (f32, f32) = s
.eval(
r#"
local _Height = Host.GetHeight
return _Height(Tex), _Height(Str)
"#,
)
.unwrap();
assert_eq!((th, sh), (32.0, 10.0));
let (tw, sw): (f32, f32) = s
.eval(
r#"
local _Width = Host.GetWidth
return _Width(Tex), _Width(Str)
"#,
)
.unwrap();
assert_eq!((tw, sw), (64.0, 50.0));
// …and the setter half, which is the same split and would have been the next wall.
s.run("local _SetW = Host.SetWidth _SetW(Tex, 99)").unwrap();
assert_eq!(s.eval::<f32>("return Tex:GetWidth()").unwrap(), 99.0);
}
/// The other direction — a method pulled off a **region** and applied to a **frame**. Same one
/// function, so this cannot be made to work by special-casing the reported direction.
#[test]
fn a_method_pulled_off_a_texture_works_on_a_frame() {
let s = vm();
let h: f32 = s.eval("local g = Tex.GetHeight return g(Host)").unwrap();
assert_eq!(h, 120.0);
let name: String = s.eval("local n = Str.GetName return n(Host)").unwrap();
assert_eq!(name, "Host");
let kind: String = s
.eval("local t = Tex.GetObjectType return t(Host)")
.unwrap();
assert_eq!(kind, "Frame");
}
/// Each receiver keeps its OWN behaviour behind the shared name — the per-kind arms are not
/// collapsed into one. `GetObjectType` is the cleanest witness (three different answers), and
/// `GetParent` is the one whose two arms genuinely differ (a region's owner is never nil).
#[test]
fn one_name_still_dispatches_per_kind() {
let s = vm();
let (f, t, g): (String, String, String) = s
.eval("return Host:GetObjectType(), Tex:GetObjectType(), Str:GetObjectType()")
.unwrap();
assert_eq!(
(f.as_str(), t.as_str(), g.as_str()),
("Frame", "Texture", "FontString")
);
// The region arm answers its OWNER; the frame arm answers nil for a parentless top frame.
let owner: String = s.eval("return Tex:GetParent():GetName()").unwrap();
assert_eq!(owner, "Host");
assert!(s.eval::<bool>("return Host:GetParent() == nil").unwrap());
}
/// `GetNumPoints` — on the Region map, so **every** widget answers it. The frame arm did not
/// exist until the map was collapsed to one implementation; the region arm shipped alone.
#[test]
fn get_num_points_answers_on_a_frame_too() {
let s = vm();
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return Tex:GetNumPoints()").unwrap(), 1);
s.run("Host:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 0, 0)")
.unwrap();
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 2);
s.run("Host:ClearAllPoints()").unwrap();
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 0);
}
/// **The carve's other edge, and the control that keeps it honest.** These six look like they
/// belong to the Region map and do not: Frame, Texture and FontString each register their *own*
/// at different addresses (`texture-fontstring-method-split.md` §3 — Texture `SetAlpha 0x79b580`
/// vs FontString `0x79cb70`, and Frame's own `0x79b...` twin). So `WorldFrame.Show(someTexture)`
/// fails on the real client, and hoisting them here to make more addon code work would be the
/// superset decision 1189 had to take back out.
#[test]
fn the_six_look_alikes_are_not_shared() {
let s = vm();
for m in [
"Show",
"Hide",
"IsShown",
"IsVisible",
"SetAlpha",
"GetAlpha",
] {
let shared: bool = s.eval(&format!("return Host.{m} == Tex.{m}")).unwrap();
assert!(
!shared,
"{m} is registered per class in 1.12 and must NOT be hoisted onto the Region map"
);
}
}
/// A receiver that is not a widget at all raises, and says *why* — the bridge resolves the
/// receiver before either arm runs, so this error must not be flattened into a generic one.
#[test]
fn a_non_widget_receiver_raises_and_names_the_reason() {
let s = vm();
let err = s
.eval::<f32>("return Host.GetHeight({})")
.unwrap_err()
.to_string();
assert!(err.contains("T[0] identity"), "unexpected error: {err}");
}
+19
View File
@@ -283,6 +283,21 @@ pub struct Frame {
/// `StartSizing` half of the same family. Default false. benilla stores and reports the flag;
/// no resize *drag* is built (`StopMovingOrSizing` already covers the stop side of both).
pub resizable: bool,
/// `SetMinResize` / `SetMaxResize` — the interactive-resize bounds (`0x776020` / `0x7762a0`,
/// with `GetMinResize 0x775f20` / `GetMaxResize 0x7761a0`; all four on the Frame method table
/// `0x878ec0`). `None` = that bound was never set, i.e. unbounded on both axes.
///
/// A window kit's opening lines are `SetResizable(true)` + these two, and without them a
/// `StartSizing` drag has nothing to stop it — Quiver's `SideEffectMakeMoveable` calls
/// `SetMinResize` on every module frame it builds and died on the nil method (part of B267).
///
/// **The sentinel and the enforcement points are out with wow-re** (`0` may be the client's
/// "unbounded", and whether the clamp binds only the drag pump or every size write is the
/// load-bearing half). Until that lands, `None` is unbounded and only the drag pump clamps —
/// `script::object::movable::clamp_resize` is the single place either answer folds into.
pub min_resize: Option<(f32, f32)>,
/// The upper twin of [`Frame::min_resize`] — see its doc.
pub max_resize: Option<(f32, f32)>,
/// `SetUserPlaced` — the client's "the user placed this frame; persist its position across
/// sessions" bit. Default false. Stored and readable (`IsUserPlaced`); **nothing consumes it
/// yet** — persisting a frame's position belongs with the layout cache, not with the drag that
@@ -578,6 +593,10 @@ impl WidgetArena {
// it, at the level it was created with.
movable: false,
resizable: false,
// No frame is born with resize bounds: both are opt-in, and a frame nobody bounded
// resizes freely (see the field docs — the client's own sentinel is out with wow-re).
min_resize: None,
max_resize: None,
user_placed: false,
toplevel: false,
scale,
+43 -12
View File
@@ -34,6 +34,9 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
gx.declined_logged = d;
}
}
// The reveal gate's "publish what you have" (see [`StaticGx::flush_now`]): consumed here, so
// one request bakes one flush's worth of dirty regions and the windows resume next frame.
let now = std::mem::take(&mut gx.flush_now);
let StaticGx {
cells,
wmos,
@@ -42,7 +45,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
..
} = &mut *gx;
for (&cell, state) in cells.iter_mut() {
if !bake_due(state, world.cells.contains_key(&cell), frame) {
if !bake_due(state, world.cells.contains_key(&cell), frame, now) {
continue;
}
state.dirty = false;
@@ -68,7 +71,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
world.cells.insert(cell, std::sync::Arc::new(baked));
}
for (&instance, state) in wmos.iter_mut() {
if !bake_due(state, world.wmos.contains_key(&instance), frame) {
if !bake_due(state, world.wmos.contains_key(&instance), frame, now) {
continue;
}
state.dirty = false;
@@ -99,7 +102,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
// grain — the sort keeps runs set-homogeneous, and the published draw carries the
// region's set list beside the per-set bounds `bake_cell` accumulated into `groups`.
for (&instance, state) in props.iter_mut() {
if !bake_due(state, world.props.contains_key(&instance), frame) {
if !bake_due(state, world.props.contains_key(&instance), frame, now) {
continue;
}
state.dirty = false;
@@ -126,15 +129,21 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
}
}
/// Is this region's bake due? A FIRST bake waits only the short window (fast appearance); a
/// Is this region's bake due? `now` is the reveal gate's request ([`StaticGx::flush_now`]) and
/// overrides every window — the arrival burst it batches is known to be over.
///
/// Otherwise: a FIRST bake waits only the short window (fast appearance); a
/// re-bake of a published region waits for the LONG quiet window (B3, decision 1432 — the
/// admission trickle arrives spaced wider than the short window, so B2 re-baked cells once
/// per arrival for minutes; 1431's cost map priced it), with the age cap so a never-quiet
/// region still consolidates.
fn bake_due(state: &super::GxCell, published: bool, frame: u32) -> bool {
fn bake_due(state: &super::GxCell, published: bool, frame: u32, now: bool) -> bool {
if !state.dirty {
return false;
}
if now {
return true;
}
let window = if published {
REBAKE_FRAMES
} else {
@@ -349,27 +358,49 @@ mod tests {
#[test]
fn the_bake_waits_short_first_and_long_after() {
let mut state = super::super::GxCell::default();
assert!(!bake_due(&state, false, 100), "clean cells are never due");
assert!(
!bake_due(&state, false, 100, false),
"clean cells are never due"
);
state.dirty = true;
state.dirty_since = 0;
state.last_change = 0;
assert!(!bake_due(&state, false, IDLE_FRAMES - 1));
assert!(!bake_due(&state, false, IDLE_FRAMES - 1, false));
assert!(
bake_due(&state, false, IDLE_FRAMES),
bake_due(&state, false, IDLE_FRAMES, false),
"first bake: short window"
);
assert!(
!bake_due(&state, true, IDLE_FRAMES),
!bake_due(&state, true, IDLE_FRAMES, false),
"published: short is not enough"
);
assert!(!bake_due(&state, true, REBAKE_FRAMES - 1));
assert!(!bake_due(&state, true, REBAKE_FRAMES - 1, false));
assert!(
bake_due(&state, true, REBAKE_FRAMES),
bake_due(&state, true, REBAKE_FRAMES, false),
"published: long window"
);
// The reveal gate's request overrides every window: a load that has finished arriving
// publishes what it holds instead of waiting out the quiet timer (decision 1498).
state.last_change = IDLE_FRAMES;
assert!(
!bake_due(&state, false, IDLE_FRAMES, false),
"still quiet-timing"
);
assert!(
bake_due(&state, false, IDLE_FRAMES, true),
"flush_now: due now"
);
assert!(bake_due(&state, true, IDLE_FRAMES, true), "…published too");
state.dirty = false;
assert!(
!bake_due(&state, false, IDLE_FRAMES, true),
"flush_now still bakes nothing that is not dirty"
);
state.dirty = true;
state.last_change = 0;
// A cell that keeps changing (never quiet) still consolidates at the age cap.
state.last_change = MAX_DIRTY_FRAMES;
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES));
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES, false));
}
/// A quiet cell bakes: one recentred mesh whose draws sort by (bucket, texture), each
+106 -4
View File
@@ -55,10 +55,13 @@
//! the aniso-8 samplers, and the two-sided normal law (the entity shader's `select` UNDOES a
//! bevy negation this pipeline never had — copying it flipped every back-facing canopy card).
//!
//! **Known B1 gaps, deliberate** (B2+ owns them): no `presentable()` coupling — an unbaked
//! cell can show a hole for a beat on a cold arrival where the merge publishes
//! `merge_pending` into the reveal cover (1419); dev-instrument identity (inspector/WOW_PICK
//! name nothing — the weld/merge trade, one side table if missed).
//! **Known B1 gaps** (B2+ owns them): dev-instrument identity (inspector/WOW_PICK name nothing
//! — the weld/merge trade, one side table if missed). The other one B1 left open — no
//! `presentable()` coupling, so an unbaked region shows a hole for a beat on a cold arrival
//! where the merge publishes `merge_pending` into the reveal cover (1419) — is **CLOSED by
//! 1498**: [`StaticGx::undrawn_regions`] is that coupling, and [`StaticGx::flush_now`] is why
//! it costs the load no extra frames. It was not a beat: it was the director's report of
//! teleporting into a Stormwind with no Stormwind in it.
//!
//! **B3 (decision 1432): the churn 1431 priced is paid.** Texture arrays are ONE SHARED POOL
//! across every cell and region (dedup by asset id; `pool.rs` owns the design note), layer
@@ -438,6 +441,9 @@ pub struct StaticGx {
/// Lifetime exile-event counters (spawns, re-admits, gone-despawns) — the census line's
/// churn instrument.
fade_events: [u32; 3],
/// [`StaticGx::flush_now`]'s request: the next flush ignores every quiet window. Consumed
/// (cleared) by that flush.
flush_now: bool,
}
/// The divert hook's per-call facts (everything is in scope at `assemble.rs`'s gate).
@@ -544,6 +550,81 @@ pub struct GxWmoBatch {
}
impl StaticGx {
/// **Collected geometry that draws nothing yet** — regions holding items whose bake has not
/// published (a first bake still inside its quiet window, [`IDLE_FRAMES`]).
///
/// A retained region is a *hole in the frame* between the divert and its first publish: the
/// entity path no longer draws that geometry (slice 2 moved WMO group geometry here
/// wholesale), so a spawned building whose region has not baked is a building that is not on
/// screen. The reveal gate ([`crate::terrain_stream::WorldLoadProgress`]) reads this, which
/// is why it is published as a fact rather than left an internal of the bake.
///
/// A *re-bake* of an already-published region is deliberately NOT counted: that region keeps
/// drawing its previous bake meanwhile, so it is content on screen, not a hole.
pub(crate) fn undrawn_regions(&self) -> usize {
let world = &self.world;
let cells = self
.cells
.iter()
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.cells.contains_key(*k))
.count();
let wmos = self
.wmos
.iter()
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.wmos.contains_key(*k))
.count();
let props = self
.props
.iter()
.filter(|(k, s)| s.dirty && !s.items.is_empty() && !world.props.contains_key(*k))
.count();
cells + wmos + props
}
/// The WMO lane's headline numbers for an instrument: regions **collected**, regions
/// **published** (baked, so drawable at all), and regions the cull **selected** this frame.
/// Collected-but-unpublished is the reveal hole [`Self::undrawn_regions`] counts; published
/// minus selected is ordinary culling.
pub fn wmo_census(&self) -> (usize, usize, usize) {
(
self.wmos.len(),
self.world.wmos.len(),
self.world.visible_wmos.len(),
)
}
/// What this frame's scene walk actually **selected to draw** — doodad/prop draw entries,
/// WMO regions selected, and the admitted group bits summed across them. The companion to
/// [`Self::wmo_census`]: that one says what geometry EXISTS and is drawable, this one says
/// how much of it the cull let through. A frame where everything is published and almost
/// nothing is selected is a visibility fault, not a residency one — the distinction the
/// reveal audit could not make.
pub fn draw_census(&self) -> (usize, usize, usize) {
let groups = self
.world
.visible_wmos
.iter()
.map(|(_, bits)| bits.iter().filter(|b| **b).count())
.sum();
(
self.world.visible.len(),
self.world.visible_wmos.len(),
groups,
)
}
/// **Bake every dirty region on the next flush, quiet window or not** — what the reveal gate
/// asks for once nothing more is arriving.
///
/// The quiet windows ([`IDLE_FRAMES`]/[`REBAKE_FRAMES`]) exist to batch an *arrival trickle*
/// during play. The end of a load is not a trickle: the streamer knows the burst is over
/// (every wanted tile spawned, every focus placement up, the collider queue quiet), and
/// waiting another 15 frames there is 15 frames of a city that has spawned and cannot draw.
/// So the end of the burst says "publish what you have" instead of arming another timer.
pub(crate) fn flush_now(&mut self) {
self.flush_now = true;
}
/// Take one eligible batch instead of spawning it (or merging it). Returns `false` — and
/// tallies why — when the batch's facts fall outside the prototype's lane; the caller then
/// falls through to the ordinary path, exactly like a refused merge divert.
@@ -882,6 +963,27 @@ mod tests {
use super::testkit::{batch, tri};
use super::*;
/// A diverted batch is collected geometry that draws NOTHING until its region bakes — the
/// reveal hole [`StaticGx::undrawn_regions`] exists to publish, and the fact the reveal gate
/// keys on (decision 1498). Publishing the region clears it; a re-bake of a published region
/// is not a hole, because the previous bake is still on screen.
#[test]
fn a_collected_region_is_undrawn_until_it_publishes() {
let mut gx = StaticGx::default();
let g = tri([0.0; 3]);
assert_eq!(
gx.undrawn_regions(),
0,
"nothing collected, nothing pending"
);
assert!(gx.divert(batch(&g, Vec3::ZERO, None, ModelBlend::Opaque)));
assert_eq!(gx.undrawn_regions(), 1, "diverted, unbaked — a hole");
// What the bake does when it publishes a region: clears `dirty`.
let cell = *gx.cells.keys().next().unwrap();
gx.cells.get_mut(&cell).unwrap().dirty = false;
assert_eq!(gx.undrawn_regions(), 0, "baked — on screen");
}
/// The collector refuses exactly the recorded exclusion families — and says so in the
/// census — while an eligible batch diverts (1429's no-silent-caps note).
#[test]
+55 -1
View File
@@ -99,6 +99,12 @@ pub struct TerrainStreamer {
/// WMO-only map that one building is the whole world, so there is nothing else to stream.
/// Cleared on map change, which also releases the placement.
global_wmo: bool,
/// [`ViewFocus::paced`] as of last frame — so the residency latch can re-arm on the EDGE into
/// a load rather than on the level. A viewer with no avatar publishes `paced: false` forever
/// (`ViewFocus::camera`), and re-arming on the level would leave that viewer walking its focus
/// neighbourhood's every placement every frame for the rest of the run — the settled-work scan
/// the latch exists to stop.
was_paced: bool,
}
/// The placement id the map-global WMO is registered under. A WMO-only map authors **no** ADT tiles
@@ -234,6 +240,16 @@ pub struct WorldLoadProgress {
pub merge_pending: usize,
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
pub placements_pending: usize,
/// Retained-pass regions collected but not yet drawable (`StaticGx::undrawn_regions`) —
/// geometry that has *spawned* and still draws nothing.
///
/// Since slice 2 (1429/1430) a WMO's group geometry leaves the entity path for the retained
/// pass, so "the placement spawned" stopped meaning "the building is on screen": between the
/// divert and its first bake the region is a hole. The reveal used to be gated on the spawn
/// alone and therefore lifted up to `IDLE_FRAMES` before the buildings could draw — a city of
/// bare ground for a quarter second. Same conservative semantics as the collider and merge
/// backlogs beside it.
pub gx_pending: usize,
/// **Which tile these facts are about** — the focus tile the streamer computed this frame.
/// `None` until the streamer first runs (and again after `release_world`). The settle release
/// compares it against the tile under the avatar's own feet and refuses the resident release on
@@ -271,7 +287,10 @@ impl WorldLoadProgress {
/// The world at the focus is presentable — the scene term plus a quiet collider queue. What
/// both consumers (the screen clear here, the settle release in the streamer) key on.
pub fn presentable(&self) -> bool {
self.scene_ready && self.colliders_pending == 0 && self.merge_pending == 0
self.scene_ready
&& self.colliders_pending == 0
&& self.merge_pending == 0
&& self.gx_pending == 0
}
}
@@ -944,6 +963,8 @@ fn stream_terrain(
// "ready to reveal" means the buildings and trees around you exist, not just bare terrain. A
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
// for ground that isn't there.
// The pacing edge, read and advanced once per frame (see [`TerrainStreamer::was_paced`]).
let was_paced = std::mem::replace(&mut state.was_paced, focus.paced);
if let Some(p) = load_progress.as_mut() {
p.focus_tile = Some((cx, cy));
// Resident = the focus tile is furnished — judged from the DESIRED set, not from the
@@ -982,6 +1003,18 @@ fn stream_terrain(
if !p.focus_resident {
p.complete = false;
}
// A **snap** re-arms it too, even onto ground that is already resident. `paced` is false
// exactly through entry, a teleport and a world swap (the game writes it from the settle
// hold), and a near teleport — `.tele` across a city, a summon — lands on furnished ground
// whose *buildings* may still be arriving. Without this the latch answers a fresh
// destination with the departure's converged numbers: `scene_ready` reads true because it
// was true where we left, and both consumers (the cover, the settle release) are told a
// world is presentable that nobody has looked at yet.
//
// On the EDGE into the load, not on the level — see [`TerrainStreamer::was_paced`].
if was_paced && !focus.paced {
p.complete = false;
}
if !p.complete {
// "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the
// cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a
@@ -1037,6 +1070,27 @@ fn stream_terrain(
}
p.complete = p.scene_ready && p.is_ready();
}
// The retained pass's own residency (1429's lane): geometry that has spawned and cannot
// draw yet is a hole in the reveal exactly like an unattached collider is a hole in the
// floor. Published every frame — one map walk, no per-item work — because both consumers
// read `presentable()` and neither can see the bake.
p.gx_pending = staticgx
.as_deref()
.map_or(0, crate::static_gx::StaticGx::undrawn_regions);
// …and once nothing else is outstanding, the burst is over by definition: ask the pass to
// publish what it holds instead of letting the quiet window add its own 15 frames to the
// end of every load. Gated on the load itself (`!paced` — entry, a teleport, a world
// swap), so steady-state play keeps the batching the windows exist for.
if !focus.paced
&& p.gx_pending > 0
&& p.scene_ready
&& p.colliders_pending == 0
&& p.merge_pending == 0
{
if let Some(gx) = staticgx.as_deref_mut() {
gx.flush_now();
}
}
// Residency is *published* here and read by the mover's post-snap hold one stage later
// (`player::release_post_snap_hold`). The streamer is still the only authority on which
// map the colliders under the avatar belong to — it just states the fact now instead of
+130
View File
@@ -182,10 +182,140 @@ pub const CAM_NEAR: f32 = 1.0 / 9.0;
/// [`crate::sun`]'s projection note); naming it here just stops the consumers drifting apart.
pub const CAM_FOVY: f32 = std::f32::consts::FRAC_PI_4;
/// **The world camera's world pose, made current before anything in `Update` reads it** — the
/// set every `Update`-stage viewer authority must order after.
///
/// Bevy propagates `GlobalTransform` in `PostUpdate`. For the whole of `Update`, therefore, a
/// camera's `GlobalTransform` is the pose it had **last** frame, while its `Transform` — written
/// by the controller in [`crate::schedule::WorldStage::Input`] — is this frame's. Walking, the two
/// differ by centimetres and nothing shows. On the frame a teleport snaps they differ by the whole
/// jump, and an authority that decides *what may draw* off the stale one gates a frame drawn from
/// the new pose with a verdict about the old place (decision 1503).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CameraPoseSet;
/// Copy the world camera's `Transform` into its `GlobalTransform` (see [`CameraPoseSet`]).
///
/// **Root cameras only.** The world camera is spawned unparented (both the fallback and the real
/// one), and for a root the propagation Bevy will run in `PostUpdate` is exactly this copy — so
/// this makes the same value available earlier and `PostUpdate` recomputes it identically. A
/// camera that ever acquires a parent keeps Bevy's propagated value and its old one-frame lag,
/// which is no worse than before and never silently wrong: the pose it reports is a real pose the
/// camera had, just not this frame's.
///
/// Written through `set_if_neq` so a still camera does not flag `Changed<GlobalTransform>` every
/// frame for the consumers that key on it.
/// The root world cameras and their two transforms — see [`publish_camera_pose`].
type RootCameraPose<'w, 's> =
Query<'w, 's, (&'static Transform, &'static mut GlobalTransform), RootCamera>;
/// A world camera Bevy will propagate as a root (no parent to inherit from).
type RootCamera = (With<WorldCamera>, Without<ChildOf>);
pub(crate) fn publish_camera_pose(mut cam: RootCameraPose) {
for (t, mut g) in &mut cam {
g.set_if_neq(GlobalTransform::from(*t));
}
}
/// The view lane's plugin — today, [`publish_camera_pose`] and its ordering contract.
pub(crate) struct ViewPlugin;
impl Plugin for ViewPlugin {
fn build(&self, app: &mut App) {
plugin(app);
}
}
pub(crate) fn plugin(app: &mut App) {
app.add_systems(
Update,
publish_camera_pose
.in_set(CameraPoseSet)
// After the controller that writes the camera's `Transform`
// ([`crate::schedule::WorldStage::Input`]) — the point of the whole system is to
// publish the pose the controller just chose, not the one before it.
.after(crate::schedule::WorldStage::Input),
);
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
/// The contract of [`CameraPoseSet`]: a system ordered after it, in the same `Update`, reads
/// the pose the controller wrote **this** frame — not the one Bevy will propagate at the end
/// of it. Written as the frame it fixes: move the camera the way a teleport snap does, and
/// assert the downstream authority sees the destination.
#[test]
fn a_reader_after_the_pose_set_sees_this_frames_camera() {
#[derive(Resource, Default)]
struct Seen(Vec3);
fn snap_the_camera(mut cam: Query<&mut Transform, With<WorldCamera>>) {
for mut t in &mut cam {
t.translation = Vec3::new(500.0, 0.0, 0.0);
}
}
fn read_the_pose(cam: Query<&GlobalTransform, With<WorldCamera>>, mut seen: ResMut<Seen>) {
for g in &cam {
seen.0 = g.translation();
}
}
let mut app = App::new();
app.init_resource::<Seen>();
app.configure_sets(Update, crate::schedule::WorldStage::Input);
app.add_systems(
Update,
snap_the_camera.in_set(crate::schedule::WorldStage::Input),
);
plugin(&mut app);
app.add_systems(Update, read_the_pose.after(CameraPoseSet));
app.world_mut().spawn((
WorldCamera,
Transform::default(),
GlobalTransform::default(),
));
app.update();
assert_eq!(
app.world().resource::<Seen>().0,
Vec3::new(500.0, 0.0, 0.0),
"an Update-stage viewer authority must see the snapped pose, not the frame-old one"
);
}
/// A **parented** camera is left to Bevy's propagation: a plain copy of a child's local
/// `Transform` would be a wrong pose, which is worse than a frame-old right one.
#[test]
fn a_parented_camera_is_left_alone() {
let mut app = App::new();
plugin(&mut app);
let parent = app.world_mut().spawn(Transform::default()).id();
let child = app
.world_mut()
.spawn((
WorldCamera,
Transform::from_xyz(7.0, 0.0, 0.0),
GlobalTransform::default(),
))
.id();
app.world_mut().entity_mut(parent).add_child(child);
app.world_mut()
.run_system_once(publish_camera_pose)
.unwrap();
assert_eq!(
app.world()
.entity(child)
.get::<GlobalTransform>()
.unwrap()
.translation(),
Vec3::ZERO,
"the child's local transform is not a world pose"
);
}
/// The wall is planar depth along camera-forward, measured to the sphere's nearest point.
#[test]
fn planar_depth_of_the_nearest_point() {
+9 -1
View File
@@ -293,7 +293,13 @@ impl Plugin for WmoPortalPlugin {
track_area_interior,
track_unit_interiors,
)
.in_set(WmoPvsSet),
.in_set(WmoPvsSet)
// **On this frame's camera pose, not last frame's** (decision 1503). The flood,
// the interior claim and the exterior windows all key on the eye, and every one of
// them decides what the frame is allowed to draw. Unordered, this set could even
// run before the controller had moved the camera at all; ordered here it answers
// about the eye the frame will be rendered from.
.after(crate::view::CameraPoseSet),
);
}
}
@@ -323,6 +329,8 @@ fn compute_wmo_pvs(
};
let clip_from_world = proj.get_clip_from_view() * cam_t.to_matrix().inverse();
let eye_world = cam_t.translation();
// Publish the pose this authority ran on (see [`WmoCullProbe::eye`]).
probe.eye = eye_world;
// The terrain leg of the seed's down-ray, sampled once for the camera's column: the client casts the
// same segment at the ground and drops the WMO hit when the ground is nearer (`FUN_006821f0`).
let terrain = terrain_height_under(&streamer, &adt_tiles, eye_world);
@@ -18,6 +18,11 @@ use super::{
pub struct WmoCullProbe {
/// Set by the panel's dump button; the next compute writes the trace file and clears it.
pub dump_requested: bool,
/// **The eye the PVS, the interior claim and the exterior windows were computed from** —
/// the visibility authority's own pose, recorded so an instrument can compare it against the
/// pose the frame actually draws from. Ordinary movement makes the two identical to within a
/// centimetre; a snap is where they can disagree by the whole teleport.
pub eye: Vec3,
}
/// Where the dump button writes its trace — under `target/` so it never lands in the repo.
+9
View File
@@ -50,6 +50,8 @@ pub struct WorldCensus<'w, 's> {
claim: Option<Res<'w, CameraInteriorClaim>>,
windows: Option<Res<'w, ExteriorWindows>>,
verdict: Option<Res<'w, ExteriorCullVerdict>>,
/// The visibility authority's own pose (see [`CensusReport::pvs_eye`]).
cull_probe: Option<Res<'w, crate::wmo_portal::WmoCullProbe>>,
/// Which backdrop is drawing — the gradient dome, or a building's own MOSB sky
/// ([`crate::wmo_sky`]). Optional for the same reason as the portal terms above.
skybox: Option<Res<'w, crate::wmo_sky::CameraWmoSkybox>>,
@@ -103,6 +105,12 @@ pub struct CensusReport {
pub room: Option<String>,
/// `"unrestricted"`, or the number of window sub-frusta. `None` with [`CensusReport::room`].
pub windows: Option<String>,
/// **The eye the portal authority computed [`Self::room`], [`Self::windows`] and every group's
/// PVS from.** Not necessarily the eye this frame draws from: the authority runs in `Update`
/// off the camera's propagated transform, so it is answering about wherever the camera was
/// when that transform was last written. Walking makes the difference a centimetre; a
/// teleport makes it the whole jump, and this is the column that says so.
pub pvs_eye: Option<Vec3>,
/// The backdrop actually drawing: `"dome"` for the `Light.dbc` gradient, else the WMO skybox
/// model a building's PVS asked for. `None` when the WMO-sky lane is not installed.
///
@@ -263,6 +271,7 @@ impl WorldCensus<'_, '_> {
particles,
room,
windows,
pvs_eye: self.cull_probe.as_deref().map(|p| p.eye),
sky,
ribbons: self.ribbons.as_deref().map(|r| (r.trails, r.drawn)),
cull: self.verdict.as_deref().map(|v| CullTerms {
@@ -177,6 +177,10 @@ impl PluginGroup for WorldPlugins {
// streamer fills its volume registry).
.add(crate::interior::InteriorPlugin)
.add(crate::entity_shade::EntityShadePlugin)
// The world camera's pose, published before the `Update`-stage viewer authorities
// below read it (decision 1503) — without it they answer about where the camera was
// last frame, which on a teleport frame is the place we just left.
.add(crate::view::ViewPlugin)
// WMO portal visibility: per-frame, decides which of a building's groups are reachable
// through portals from the camera's group, so the Stormwind cathedral culls from the
// Trade District. Only computes the PVS; the `Visibility` authority