diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 0a10b5a9..e3f2892a 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -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. -->
-
+
@@ -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" },
})
diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs
index 387d7458..dc1e3bb3 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -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};
diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs
index d92792cb..5a91fd05 100644
--- a/crates/benilla-app/src/capture/probes/mod.rs
+++ b/crates/benilla-app/src/capture/probes/mod.rs
@@ -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;
diff --git a/crates/benilla-app/src/capture/probes/reveal.rs b/crates/benilla-app/src/capture/probes/reveal.rs
new file mode 100644
index 00000000..abab995b
--- /dev/null
+++ b/crates/benilla-app/src/capture/probes/reveal.rs
@@ -0,0 +1,137 @@
+//! The **reveal audit** (`WOW_REVEAL=`) — 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,
+ /// [`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,
+ progress: Res,
+ gx: Option>,
+ screen: Res,
+ player: Option>,
+ census: WorldCensus,
+ cam: Query<&GlobalTransform, With>,
+ time: ProbeClock,
+ mut teleports: MessageReader,
+ mut worldports: MessageReader,
+) {
+ 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("-"),
+ );
+}
diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs
index 49364655..a6da4070 100644
--- a/crates/benilla-app/src/chat_bubble.rs
+++ b/crates/benilla-app/src/chat_bubble.rs
@@ -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 = 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![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]
diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs
index 05eacc2a..7e569632 100644
--- a/crates/benilla-app/src/combat_text/mod.rs
+++ b/crates/benilla-app/src/combat_text/mod.rs
@@ -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);
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`).
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 1c41b60f..c7d703b6 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -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,
mut bubbles: ResMut,
mut zoom: ResMut,
- mut follow: ResMut,
+ mut follow: ResMut,
mut video: ResMut,
mut pane_rate: ResMut,
) {
@@ -427,7 +445,7 @@ fn sync_cvars(
mut minimap: ResMut,
mut bubbles: ResMut,
mut zoom: ResMut,
- mut follow: ResMut,
+ mut follow: ResMut,
mut video: ResMut,
mut pane_rate: ResMut,
) {
@@ -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>,
Option>,
Option>,
- Option>,
+ Option>,
Option>,
Option>,
)> = 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::().unwrap()
+ follow.style.cvar().parse::().unwrap()
);
+ assert_eq!(
+ d["cameraSmoothTrackingStyle"],
+ follow.tracking_style.cvar().parse::().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::()
.init_resource::()
.init_resource::()
- .init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.add_plugins(CvarPlugin);
@@ -1024,7 +1064,7 @@ mod tests {
.init_resource::()
.init_resource::()
.init_resource::()
- .init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.add_plugins(CvarPlugin);
diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs
index 084b0b6b..63f6fba3 100644
--- a/crates/benilla-app/src/dev.rs
+++ b/crates/benilla-app/src/dev.rs
@@ -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=` 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=` 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() {
diff --git a/crates/benilla-app/src/loading_screen.rs b/crates/benilla-app/src/loading_screen.rs
index 0ce214b6..3f2340ad 100644
--- a/crates/benilla-app/src/loading_screen.rs
+++ b/crates/benilla-app/src/loading_screen.rs
@@ -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,
}
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. ---
diff --git a/crates/benilla-app/src/pipe_warm/mod.rs b/crates/benilla-app/src/pipe_warm/mod.rs
index 7d49c75b..85225680 100644
--- a/crates/benilla-app/src/pipe_warm/mod.rs
+++ b/crates/benilla-app/src/pipe_warm/mod.rs
@@ -290,6 +290,16 @@ pub(crate) struct WarmPass {
effect_tex: Option>,
/// 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!(
diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs
index 085e2a31..147afbbe 100644
--- a/crates/benilla-app/src/player.rs
+++ b/crates/benilla-app/src/player.rs
@@ -189,7 +189,7 @@ impl Plugin for PlayerPlugin {
);
app.init_resource::();
app.init_resource::();
- app.init_resource::();
+ app.init_resource::();
// 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,
Res,
Res,
- Res,
+ Res,
),
// 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,
diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs
index 18a5e7f9..c186806d 100644
--- a/crates/benilla-app/src/player/camera.rs
+++ b/crates/benilla-app/src/player/camera.rs
@@ -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 = 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