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