From afc0731ccc5da8cf580e445e577f090361cf3e21 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 11 Sep 2026 20:55:15 +0700 Subject: [PATCH] nameplates are frames, guild names go overhead, and the camera pivots - a nameplate is a real frame under WorldFrame, six regions and a health bar in the reference's order, so pfUI, ShaguTweaks and _Nameplates find and restyle plates instead of seeing none; the plate stays out of the mouse's way in this cut (2148) - a player's guild shows under their overhead name in angle brackets, the line an NPC's subname already used, gated by the UnitNamePlayerGuild option at its stock on (2149) - smart camera pivot is on, as 1.12 ships it: a mostly vertical drag with the camera pressed against something tilts the view instead of swinging the arm, easing home on release; in water the pivot rises for a swimmer (2149) - Follow Terrain and head bob are built and ship off, as in 1.12: Follow Terrain leans the camera up or down the slope you are walking onto, and head bob sways the first-person eye in a figure of eight while you move (2149) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/cvars.rs | 206 +- crates/benilla-app/src/nameplates.rs | 96 +- crates/benilla-app/src/player.rs | 6 + crates/benilla-app/src/player/camera.rs | 301 ++- .../benilla-app/src/player/camera_channel.rs | 377 ++++ .../benilla-app/src/player/camera_dynamics.rs | 1793 +++++++++++++++++ crates/benilla-app/src/player/controller.rs | 64 +- crates/benilla-app/src/ui_guild/mod.rs | 66 + .../benilla-app/src/ui_script/extract/mod.rs | 25 +- .../src/ui_script/options_tests.rs | 48 - crates/benilla-app/src/ui_script/session.rs | 5 + crates/benilla-app/src/vplates.rs | 491 +---- crates/benilla-app/src/vplates/border.rs | 2 +- crates/benilla-assets/src/world_assets/mod.rs | 42 + crates/benilla-ui/src/script/mod.rs | 2 + crates/benilla-ui/src/script/model.rs | 6 + crates/benilla-ui/src/script/nameplate.rs | 668 ++++++ crates/benilla-ui/src/script/statusbar.rs | 26 + crates/benilla-ui/src/script/tests/mod.rs | 1 + .../benilla-ui/src/script/tests/nameplate.rs | 489 +++++ 20 files changed, 4166 insertions(+), 548 deletions(-) create mode 100644 crates/benilla-app/src/player/camera_channel.rs create mode 100644 crates/benilla-app/src/player/camera_dynamics.rs create mode 100644 crates/benilla-ui/src/script/nameplate.rs create mode 100644 crates/benilla-ui/src/script/tests/nameplate.rs diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 3c4a2487..bbc6993f 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -450,6 +450,14 @@ pub(crate) const REGISTERED: &[Registered] = &[ same("UnitNamePlayer", "1"), same("UnitNameNPC", "0"), same("UnitNameOwn", "0"), + // The fourth of the same registrar's five (2149): `UnitNamePlayerGuild` `0x86c680` -> `"1"` + // `0x82e748`, mask bit `0x10`. It is NOT a show gate like the three above — `ShouldShowName + // 0x6070a0` consults only bits `0x1/0x2/0x4` — it gates ONE LINE of the player stack, the a5 + // `"\n<%s>"` guild decoration at `0x609085` (wow-re `object-layer/scratch/overhead-name.md` + // Q4 point 3 + the registrar table). Its fifth sibling `UnitNamePlayerPVPTitle` (bit `0x20`, + // also `"1"`) has no row: a4's rank prefix needs a faction side `ui_unit` cannot resolve for + // an arbitrary player, so there is no reader and 1134 §4 says no key. + same("UnitNamePlayerGuild", "1"), // The two V-plate toggles over `VPlateMode` — the engine bitmask `[0xc4da34]`'s bit 0 and // bit 3. 1.12 registers NO nameplate CVar (wow-re, VERIFIED — the bitmask is a plain runtime // global, persisted FrameXML-side as the `RegisterForSave`'d `NAMEPLATES_ON` / @@ -665,6 +673,69 @@ pub(crate) const REGISTERED: &[Registered] = &[ // 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. same("cameraYawSmoothSpeed", "180"), + // **The four 1.12 camera-option toggles** (decision 2149) — the `UIOptionsFrame` checkboxes + // FOLLOW_TERRAIN / HEAD_BOB / SMART_PIVOT / WATER_COLLISION, all four of which sat on the + // unbacked-CVar census with a byte-level spec and no feature until now. Defaults are the + // registrar's own (`re/cvar/cvar-register-sites.tsv`), and two of them are **"1"** — which is + // why building them was not cosmetic: benilla was the divergence on those, not the reference. + // + // `cameraPivot` `[0xbe10a4]` "1" (`0x50bda3`) — smart pivot. Mechanism: wow-re + // `ui/scratch/camera-cvar-gates.md` §3 (gate `0x510690`, routing `0x50fee0`, release + // `0x5107f0`); ours is `player::camera_dynamics::SmartPivot`. + same("cameraPivot", "1"), + // Its two drag-shape thresholds, both read by that routing (`0x50fff5`/`0x510004`) and both + // in RADIANS of camera rotation — the deltas they are compared against are already scaled by + // `cameraMoveSpeed · π/180`, so unlike the sensitivity itself these two transfer + // to benilla's raw-device units exactly (see `camera::LOOK_YAW_PER_SPEED`'s note). + same("cameraPivotDXMax", "0.05"), + same("cameraPivotDYMin", "0"), + // The rate the pitch bias eases back on once the pivot lets go, deg/s (`[0xbe0fc8]`, + // `0x512a50`'s `duration = |Δ| / (rate · π/180)`). No panel row here or there — the reader is + // the host, exactly like `cameraSmoothTrackingStyle` above it. + same("cameraTargetSmoothSpeed", "90"), + // `cameraWaterCollision` `[0xbe1088]` "1" (`0x50bd63`) — the other of the two that ships ON. + // Two consumers (wow-re `camera-cvar-gates.md` §4): the solver's sweep class word gains the + // `0xf0000` ADT-liquid nibble at `0x50e5ec`, and the driver's band-CROSSING pitch kick at + // `0x50ecae`. What it actually MOVES is the framing pivot's floor/cap, re-based by `0x511ad0`'s + // bands (`pivot-height-glide.md` §5) — the arm itself stays liquid-blind either way, which is + // that note's own verdict and is why benilla having no liquid collider costs nothing here. + same("cameraWaterCollision", "1"), + // Its band-crossing pair, both `"5.0"` (`[0xbe0f2c]`/`[0xbe0fdc]`) — the ABSOLUTE pitch the + // camera eases to when the liquid band crosses, in the reference's own degrees (positive = + // looking down; `player::camera_dynamics::WaterPitch` negates once, for benilla's sign). + same("cameraSurfaceFinalPitch", "5"), + same("cameraSubmergeFinalPitch", "5"), + // **`cameraPitchSmoothSpeed`** (`[0xbe0ce4]`, "45.0") — 2115 §7's third partner write, and + // 2147 §7's "the only one still unbuilt: no reader, so no row". It has one now, but a PARTIAL + // one, and the row says so rather than implying the whole channel exists: it is the rate the + // water kick's own pitch channel arms at. The reference reads it for the aim pitch as a whole + // (`0x510120` arms `[cam+0x1e0]`, `0x50f160` eases `[cam+0xf4]`); benilla's mouse pitch is an + // immediate write and stays one — see `WaterPitch`'s divergence note. + same("cameraPitchSmoothSpeed", "45"), + // `cameraTerrainTilt` `[0xbe0fd4]` **"0"** (`0x50bcfd`) — Follow Terrain, and the one of the + // four that ships OFF, so building it changed nothing until a player ticks the box. Mechanism: + // wow-re `camera-cvar-kernels.md` §2 (the ahead-probe and the five-step staircase) and + // `camera-smooth-style.md` §9 (the arm); ours is `player::camera_dynamics::TerrainTilt`. + same("cameraTerrainTilt", "0"), + // The ground channel's rate, deg/s (`[0xbe0fc0]`) and the duration bound its `Factor` scales + // (`[0xbe1050]`/`[0xbe1054]`, seconds). The floor always binds — `20° / 7.5°/s` is 2.67 s + // against a 3 s minimum — which is why a followed terrain leans rather than tracks. + same("cameraGroundSmoothSpeed", "7.5"), + same("cameraTerrainTiltTimeMin", "3"), + same("cameraTerrainTiltTimeMax", "10"), + // `cameraBobbing` `[0xbe10c0]` **"0"** (`0x50b76d`) — head bob, the fourth of the four and the + // second that ships OFF. Mechanism: wow-re `camera-cvar-kernels.md` §4 and + // `camera-cvar-gates.md` §2; ours is `player::camera_dynamics::HeadBob`. + same("cameraBobbing", "0"), + // Its four numeric siblings. The two amplitudes are in the CVar's own units — the kernel + // scales both by 1/36 (`[0x7ff9d0]`) to reach yards. `cameraBobbingSmoothSpeed` is the odd one + // and its name is the trap: it is **not** a bob rate, it is the DECAY rate, and its single + // image-wide read is in the disarm `0x51113a`, where `|largest component| / speed` becomes the + // ramp's duration (~0.069 s at these defaults). + same("cameraBobbingLRAmplitude", "2"), + same("cameraBobbingUDAmplitude", "2"), + same("cameraBobbingFrequency", "0.8"), + same("cameraBobbingSmoothSpeed", "0.8"), // 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 @@ -1129,6 +1200,7 @@ pub(crate) struct KnobParams<'w> { bubbles: ResMut<'w, BubbleConfig>, zoom: ResMut<'w, ZoomLimit>, follow: ResMut<'w, FollowConfig>, + camera_opts: ResMut<'w, crate::player::camera_dynamics::CameraOptions>, video: ResMut<'w, VideoConfig>, render_scale: ResMut<'w, RenderScale>, tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>, @@ -1171,6 +1243,7 @@ impl KnobParams<'_> { bubbles: &mut self.bubbles, zoom: &mut self.zoom, follow: &mut self.follow, + camera_opts: &mut self.camera_opts, video: &mut self.video, render_scale: &mut self.render_scale, tex_filter: &mut self.tex_filter, @@ -1209,6 +1282,7 @@ struct Knobs<'a> { bubbles: &'a mut BubbleConfig, zoom: &'a mut ZoomLimit, follow: &'a mut FollowConfig, + camera_opts: &'a mut crate::player::camera_dynamics::CameraOptions, video: &'a mut VideoConfig, render_scale: &'a mut RenderScale, tex_filter: &'a mut benilla_assets::TexFilterSetting, @@ -1364,6 +1438,27 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { "unitnameplayer" => knobs.names.player = v != 0.0, "unitnamenpc" => knobs.names.npc = v != 0.0, "unitnameown" => knobs.names.own = v != 0.0, + "unitnameplayerguild" => knobs.names.player_guild = v != 0.0, + // The camera options (2149). The three numeric ones take the value straight: the + // reference's own validator on them is `0x50b330`'s range REFUSAL, which lives in + // `benilla_ui`'s `SetCVar` path, not here. + "camerapivot" => knobs.camera_opts.pivot = v != 0.0, + "camerapivotdxmax" => knobs.camera_opts.pivot_dx_max = v, + "camerapivotdymin" => knobs.camera_opts.pivot_dy_min = v, + "cameratargetsmoothspeed" => knobs.camera_opts.target_smooth_speed = v, + "camerawatercollision" => knobs.camera_opts.water_collision = v != 0.0, + "camerasurfacefinalpitch" => knobs.camera_opts.surface_final_pitch = v, + "camerasubmergefinalpitch" => knobs.camera_opts.submerge_final_pitch = v, + "camerapitchsmoothspeed" => knobs.camera_opts.pitch_smooth_speed = v, + "cameraterraintilt" => knobs.camera_opts.terrain_tilt = v != 0.0, + "cameragroundsmoothspeed" => knobs.camera_opts.ground_smooth_speed = v, + "cameraterraintilttimemin" => knobs.camera_opts.tilt_time_min = v, + "cameraterraintilttimemax" => knobs.camera_opts.tilt_time_max = v, + "camerabobbing" => knobs.camera_opts.bobbing = v != 0.0, + "camerabobbinglramplitude" => knobs.camera_opts.bob_lr_amplitude = v, + "camerabobbingudamplitude" => knobs.camera_opts.bob_ud_amplitude = v, + "camerabobbingfrequency" => knobs.camera_opts.bob_frequency = v, + "camerabobbingsmoothspeed" => knobs.camera_opts.bob_smooth_speed = v, // The two V-plate toggles — the bitmask's two bits, flags like every other checkbox. // Lowercased here like every arm; `VPlateMode`'s consts carry the registered spelling. "nameplateshowenemies" => knobs.plates.enemies = v != 0.0, @@ -1681,6 +1776,7 @@ fn sync_cvars( // Read-only borrows for the seed: field access through `ResMut`'s `Deref` flags nothing, // which is the half of 0992's change-detection trap this system has to keep. let KnobParams { + camera_opts, sound, scale, view, @@ -1737,7 +1833,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 69] = [ + let session: [(&str, String); 87] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1774,6 +1870,56 @@ fn sync_cvars( follow.tracking_style.cvar().to_string(), ), ("cameraYawSmoothSpeed", follow.yaw_speed.to_string()), + ("cameraPivot", flag(camera_opts.pivot)), + ("cameraPivotDXMax", camera_opts.pivot_dx_max.to_string()), + ("cameraPivotDYMin", camera_opts.pivot_dy_min.to_string()), + ( + "cameraTargetSmoothSpeed", + camera_opts.target_smooth_speed.to_string(), + ), + ("cameraWaterCollision", flag(camera_opts.water_collision)), + ( + "cameraSurfaceFinalPitch", + camera_opts.surface_final_pitch.to_string(), + ), + ( + "cameraSubmergeFinalPitch", + camera_opts.submerge_final_pitch.to_string(), + ), + ( + "cameraPitchSmoothSpeed", + camera_opts.pitch_smooth_speed.to_string(), + ), + ("cameraTerrainTilt", flag(camera_opts.terrain_tilt)), + ( + "cameraGroundSmoothSpeed", + camera_opts.ground_smooth_speed.to_string(), + ), + ( + "cameraTerrainTiltTimeMin", + camera_opts.tilt_time_min.to_string(), + ), + ( + "cameraTerrainTiltTimeMax", + camera_opts.tilt_time_max.to_string(), + ), + ("cameraBobbing", flag(camera_opts.bobbing)), + ( + "cameraBobbingLRAmplitude", + camera_opts.bob_lr_amplitude.to_string(), + ), + ( + "cameraBobbingUDAmplitude", + camera_opts.bob_ud_amplitude.to_string(), + ), + ( + "cameraBobbingFrequency", + camera_opts.bob_frequency.to_string(), + ), + ( + "cameraBobbingSmoothSpeed", + camera_opts.bob_smooth_speed.to_string(), + ), ("autoLootDefault", flag(loot.auto_loot)), ("showLootSpam", flag(loot.show_loot_spam)), ("guildMemberNotify", flag(guild_notify.0)), @@ -1781,6 +1927,7 @@ fn sync_cvars( ("UnitNamePlayer", flag(names.player)), ("UnitNameNPC", flag(names.npc)), ("UnitNameOwn", flag(names.own)), + ("UnitNamePlayerGuild", flag(names.player_guild)), (crate::vplates::CVAR_ENEMIES, flag(plates.enemies)), (crate::vplates::CVAR_FRIENDS, flag(plates.friends)), // The session density on the panel scale (×1..×3 → 0..2). An env-driven off-grid @@ -2247,9 +2394,59 @@ mod tests { assert_eq!(d["UnitNamePlayer"] != 0.0, names.player); assert_eq!(d["UnitNameNPC"] != 0.0, names.npc); assert_eq!(d["UnitNameOwn"] != 0.0, names.own); + assert_eq!(d["UnitNamePlayerGuild"] != 0.0, names.player_guild); assert!( - names.player && !names.npc && !names.own, - "the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\"" + names.player && !names.npc && !names.own && names.player_guild, + "the binary registers UnitNamePlayer \"1\", NPC \"0\", Own \"0\", \ + PlayerGuild \"1\"" + ); + // The camera options weld to `CameraOptions::default()` the same way (2149) — and the two + // that matter here are the ones registered "1": a `cameraPivot` that shipped OFF would be + // benilla diverging from the reference on a feature it now has. + let camera_opts = crate::player::camera_dynamics::CameraOptions::default(); + assert_eq!(d["cameraPivot"] != 0.0, camera_opts.pivot); + assert_eq!( + d["cameraWaterCollision"] != 0.0, + camera_opts.water_collision + ); + assert!( + camera_opts.pivot && camera_opts.water_collision, + "the binary registers cameraPivot and cameraWaterCollision both \"1\"" + ); + assert_eq!( + d["cameraSurfaceFinalPitch"], + camera_opts.surface_final_pitch + ); + assert_eq!( + d["cameraSubmergeFinalPitch"], + camera_opts.submerge_final_pitch + ); + assert_eq!(d["cameraPitchSmoothSpeed"], camera_opts.pitch_smooth_speed); + assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt); + assert!( + !camera_opts.terrain_tilt, + "the binary registers cameraTerrainTilt \"0\"" + ); + assert_eq!( + d["cameraGroundSmoothSpeed"], + camera_opts.ground_smooth_speed + ); + assert_eq!(d["cameraTerrainTiltTimeMin"], camera_opts.tilt_time_min); + assert_eq!(d["cameraTerrainTiltTimeMax"], camera_opts.tilt_time_max); + assert_eq!(d["cameraBobbing"] != 0.0, camera_opts.bobbing); + assert!( + !camera_opts.bobbing && !camera_opts.terrain_tilt, + "the binary registers cameraBobbing and cameraTerrainTilt both \"0\"" + ); + assert_eq!(d["cameraBobbingLRAmplitude"], camera_opts.bob_lr_amplitude); + assert_eq!(d["cameraBobbingUDAmplitude"], camera_opts.bob_ud_amplitude); + assert_eq!(d["cameraBobbingFrequency"], camera_opts.bob_frequency); + assert_eq!(d["cameraBobbingSmoothSpeed"], camera_opts.bob_smooth_speed); + assert_eq!(d["cameraPivotDXMax"], camera_opts.pivot_dx_max); + assert_eq!(d["cameraPivotDYMin"], camera_opts.pivot_dy_min); + assert_eq!( + d["cameraTargetSmoothSpeed"], + camera_opts.target_smooth_speed ); // The V-plate pair welds to VPlateMode's defaults — both OFF, which is the reference's // own boot state on both of its halves (the `[0xc4da34]` bitmask and FrameXML's @@ -2348,7 +2545,9 @@ mod tests { let mut fps_journal = crate::perf::FpsJournalSetting::default(); let mut text_filter = crate::text_filter::TextFilterSwitches::default(); let mut game_tip = crate::game_tip::GameTipSetting::default(); + let mut camera_opts = crate::player::camera_dynamics::CameraOptions::default(); let mut knobs = Knobs { + camera_opts: &mut camera_opts, sound: &mut sound, auto_self_cast: &mut auto_self_cast, text_filter: &mut text_filter, @@ -2684,6 +2883,7 @@ mod tests { formats: vec![(32, 32, 1), (32, 32, 2), (32, 32, 4)], }) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index dee2b105..0c30291f 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -47,11 +47,26 @@ //! byte leg is a flagged pin); players → `UnitNamePlayer` (default ON); NPCs → `UnitNameNPC` //! and own → `UnitNameOwn` (both default OFF, the binary's own — see [`NameConfig`]). //! - **Lines** (`0x608f50`): NPC = name + `` (an empty wire subname is no line); player = -//! [flag prefixes +] name (guild/PVP-title lines wait on data that doesn't stream yet). The -//! prefixes are the a1–a3 vtable-slot decorations of the line stack (`+0x7c/+0x80/+0x84`), -//! glued straight onto the name by the `%s%s…` assembly — ``/``/`` in that slot -//! order from `PLAYER_FLAGS` (see [`flag_prefix`]; byte-verified, wow-re Q4a). The trio is -//! unique to the overhead name binary-wide (`CHAT_FLAG_GM`'s only xref is the a3 slot). +//! [flag prefixes +] name + ``. The prefixes are the a1–a3 vtable-slot decorations of the +//! line stack (`+0x7c/+0x80/+0x84`), glued straight onto the name by the `%s%s…` assembly — +//! ``/``/`` in that slot order from `PLAYER_FLAGS` (see [`flag_prefix`]; +//! byte-verified, wow-re Q4a). The trio is unique to the overhead name binary-wide +//! (`CHAT_FLAG_GM`'s only xref is the a3 slot). +//! +//! **The guild line is a5 and the subname line is a6, and the two can never both appear** — +//! wow-re Q4's *branch exclusivity*, VERIFIED: the player branch emits a1–3 · a4 · a5 · a8 and +//! never reaches a6/a7; the NPC branch emits a1–3 · a4 · a6 · a7 and never reaches a5/a8. Both +//! slots share the one format string `"\n<%s>"` (`0x860f9c`), so one `Option<&str>` carries +//! whichever the unit's branch supplies ([`lines_current`]'s `bracketed`). a5 alone is +//! CVar-gated — mask bit `0x10`, `UnitNamePlayerGuild`, registered `"1"` (`0x609085`); the +//! guild NAME comes from the identity cache `GetGuildInfo` reads +//! ([`crate::ui_guild::unit_guild_name`]), so an unqueried guild draws no line and the line +//! appears when the query answers. +//! +//! **Still absent, and it is a5's twin:** a4's PvP rank/title prefix (`UnitNamePlayerPVPTitle`, +//! bit `0x20`) — the rank byte streams, but `PVP_RANK_%d_%d`'s second index is a faction side +//! `ui_unit` does not resolve for an arbitrary player yet. a7 (the NPC relationship line) and a8 +//! (the foreign-server label) have no cross-realm wire here at all. use std::collections::HashMap; @@ -90,6 +105,10 @@ pub(crate) struct NameConfig { pub(crate) player: bool, pub(crate) npc: bool, pub(crate) own: bool, + /// `UnitNamePlayerGuild` — mask bit `0x10`, registered `"1"`. Unlike the trio above this one + /// does **not** gate a whole name: it gates one *line* of the player stack (`0x609085`, the a5 + /// slot), which is why it reads at the line build rather than in the show ladder. + pub(crate) player_guild: bool, } impl Default for NameConfig { @@ -98,6 +117,7 @@ impl Default for NameConfig { player: true, npc: false, own: false, + player_guild: true, } } } @@ -129,8 +149,14 @@ fn flag_prefix(player_flags: u32) -> String { /// these inputs — compared in place, because the steady frame must not allocate three strings per /// shown unit just to learn nothing changed. Equivalent to string equality against the built stack /// (the differential test pins it): line 0 is the set [`FLAG_PREFIXES`] tags in slot order glued -/// onto the name, line 1 the `<{sub}>` decoration when a subname shows. -fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<&str>) -> bool { +/// onto the name, line 1 the `<{bracketed}>` decoration — the a5 guild line for a player, the a6 +/// subname line for an NPC, never both (the module doc's branch exclusivity). +fn lines_current( + cached: &[String], + player_flags: u32, + name: &str, + bracketed: Option<&str>, +) -> bool { let name_line = |line: &str| { let mut rest = line; for (bit, tag) in FLAG_PREFIXES { @@ -143,14 +169,14 @@ fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<& } rest == name }; - match (cached, sub) { + match (cached, bracketed) { ([l0], None) => name_line(l0), - ([l0, l1], Some(sub)) => { + ([l0, l1], Some(bracketed)) => { name_line(l0) && l1 .strip_prefix('<') .and_then(|r| r.strip_suffix('>')) - .is_some_and(|r| r == sub) + .is_some_and(|r| r == bracketed) } _ => false, } @@ -385,6 +411,9 @@ pub(crate) fn drive_nameplates( Res, ), mut names: ResMut, + // The guild-identity cache (1257) — the a5 line's text, and the lazy `CMSG_GUILD_QUERY` a + // miss sends. `ResMut` because the read IS the ask ([`crate::ui_guild::unit_guild_name`]). + mut guilds: ResMut, net_commands: Res, factions: Option>, reputations: Res, @@ -551,14 +580,19 @@ pub(crate) fn drive_nameplates( } else { flags }; - let sub = if net.kind == EntityKind::Unit { - benilla_protocol::guid::entry(guid.0) + // The one `"\n<%s>"` slot: a6 (the creature subtitle) on the NPC branch, a5 (the guild) + // on the player branch — never both, and never in the other's branch (the module doc's + // branch exclusivity, wow-re Q4, VERIFIED). a5 is the only one of the two that a CVar + // gates: mask bit `0x10` at `0x609085`. + let bracketed = match net.kind { + EntityKind::Unit => benilla_protocol::guid::entry(guid.0) .and_then(|e| names.creature_subname(e)) // vmangos ships "" (present-but-empty) for most templates — an empty wire // subname is NO subname, never an empty `<>` line. - .filter(|s| !s.trim().is_empty()) - } else { - None + .filter(|s| !s.trim().is_empty()), + EntityKind::Player if name_cfg.player_guild => store + .and_then(|s| crate::ui_guild::unit_guild_name(&s.0, &mut guilds, &net_commands)), + _ => None, }; // The color: the ring's byte-verified rank + the shared palette selector. let rank = ring_reaction( @@ -592,7 +626,7 @@ pub(crate) fn drive_nameplates( // The steady frame — very nearly all of them — compares the cached stack in place // ([`lines_current`]): the line stack used to be BUILT here (three allocations per // shown unit per frame) only to equality-compare against the cache and be dropped. - Some((_, l, c)) if *c == color && lines_current(l, flags, name, sub) => {} + Some((_, l, c)) if *c == color && lines_current(l, flags, name, bracketed) => {} stale => { if let Some((old, _, _)) = stale { let old = *old; @@ -608,8 +642,8 @@ pub(crate) fn drive_nameplates( } else { format!("{prefix}{name}") }]; - if let Some(sub) = sub { - lines.push(format!("<{sub}>")); + if let Some(bracketed) = bracketed { + lines.push(format!("<{bracketed}>")); } debug!("nameplates: rebuild {entity} -> {lines:?} ({color:?})"); let mesh = plates @@ -934,10 +968,10 @@ mod tests { /// flagged `Bob`, exactly as the built strings do. #[test] fn lines_current_matches_the_built_stack() { - let build = |flags: u32, name: &str, sub: Option<&str>| { + let build = |flags: u32, name: &str, bracketed: Option<&str>| { let mut lines = vec![format!("{}{name}", flag_prefix(flags))]; - if let Some(sub) = sub { - lines.push(format!("<{sub}>")); + if let Some(bracketed) = bracketed { + lines.push(format!("<{bracketed}>")); } lines }; @@ -948,14 +982,22 @@ mod tests { (0x2, "Bob", None), (0x2 | 0x8, "Bob", None), (0, "Bob", None), + // The a5 slot (2149). It shares the a6 shape exactly, which is the point — the two + // are one `"\n<%s>"` and the unit's branch picks which fills it. The guild names + // below are the awkward ones: a guild the player left (`None` again), one whose own + // name carries the brackets, and one that collides with a subname above. + (0, "Bob", Some("Legacy")), + (0x2, "Bob", Some("Legacy")), + (0, "Bob", Some("")), + (0, "Young Wolf", Some("Beast Handlers")), ]; - for &(flags, name, sub) in cases { - let cached = build(flags, name, sub); - for &(f2, n2, s2) in cases { + for &(flags, name, bracketed) in cases { + let cached = build(flags, name, bracketed); + for &(f2, n2, b2) in cases { assert_eq!( - lines_current(&cached, f2, n2, s2), - build(f2, n2, s2) == cached, - "cache of ({flags:#x}, {name:?}, {sub:?}) vs ({f2:#x}, {n2:?}, {s2:?})" + lines_current(&cached, f2, n2, b2), + build(f2, n2, b2) == cached, + "cache of ({flags:#x}, {name:?}, {bracketed:?}) vs ({f2:#x}, {n2:?}, {b2:?})" ); } } diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index f6dc3abd..b8b55426 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -44,6 +44,11 @@ mod arc; // Writing the frame onto the body we drive — pose, MovementState, the counter-twist gap. mod body_pose; pub(crate) mod camera; +// The one smoothed-scalar channel the reference instantiates four times (wow-re +// `camera-cvar-gates.md` §8) — pitch, pitch-bias, ground tilt and the pivot height, one template. +mod camera_channel; +// The four 1.12 camera option toggles and the mechanisms behind them (decision 2149). +pub(crate) mod camera_dynamics; // The per-frame controller itself — the one system, split out so the root stays the map. mod controller; mod world_focus; @@ -324,6 +329,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 diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 463c1b41..7b9a6b44 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -12,6 +12,8 @@ use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow}; use avian3d::prelude::*; +use super::camera_channel::{Arm, SmoothChannel}; +use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt, WaterPitch}; use crate::creature_anim::wrap_pi; use crate::net::Embodied; use crate::ui_script::PointerOverUi; @@ -686,15 +688,14 @@ pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 { /// cosine profile peaks at `π/2 ×` it in the middle and is zero at both ends. There is no duration /// clamp on this channel (unlike the yaw channel's `[0.1 s, 2.0 s]`). const CAM_PIVOT_SMOOTH_SPEED: f32 = 1.2; -/// The pivot setter's "already there / already arming this" epsilon — VERIFIED `0.001` (`[0x801360]`, -/// `0x5126b0`). It is what makes the per-frame re-arm a no-op in steady state. -const CAM_PIVOT_EPS: f32 = 0.001; - -/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing the -/// model-derived target with a cosine smoothstep instead of taking it raw. +/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing +/// the model-derived target with a cosine smoothstep instead of taking it raw. /// /// The reference's live `cam+0xfc` chasing target `cam+0x1c8`: armed by `0x5126b0` → `0x512790`, -/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block (wow-re `pivot-height-glide.md`, §5 round). +/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block (wow-re `pivot-height-glide.md`, §5 round) +/// — the **fourth instantiation** of the channel template [`SmoothChannel`] holds (wow-re +/// `camera-cvar-gates.md` §8), which is why nothing of the tween lives here any more. What is left +/// is the two things this instantiation does that its three siblings do not. /// **This is why a druid shapeshift does not snap the reference's camera**, and it glides in *both* /// directions: the solver's `max(target, live)` (`0x50e5a9`) is only the collision-corridor seed, and /// the far chain clamps the result back down to the live value (`0x50e767`), so an unobstructed pivot @@ -708,92 +709,42 @@ const CAM_PIVOT_EPS: f32 = 0.001; /// skips the whole camera update while the preset is stale, `0x50e907`). Ours is the `None` target: /// during the frames a swapped-in model is loading, the pivot stays where it is and one glide runs /// when the new height lands. +#[derive(Default)] pub(super) struct PivotGlide { - /// The live height — what the camera uses (`cam+0xfc`). - live: f32, - /// Where the move started (`cam+0x1cc`) and where it is going (`cam+0x1c8`). - from: f32, - to: f32, - /// Seconds since arming, and the move's total (`cam+0x1c0`/`+0x1c4`); `None` = nothing in - /// flight (the reference's armed bit `[cam+0x90] & 0x20000000`). - flight: Option<(f32, f32)>, + /// The channel itself — **linear**, not angular: its live value is yards, so the armer's `2π` + /// rewrap must not run on it. + channel: SmoothChannel, /// Has the channel ever been armed? The reference's latch bit `0x80` — false only until the /// first model-derived height arrives, which is therefore a snap. seeded: bool, } -impl Default for PivotGlide { - fn default() -> Self { - Self { - live: CAM_PIVOT_FALLBACK, - from: CAM_PIVOT_FALLBACK, - to: CAM_PIVOT_FALLBACK, - flight: None, - seeded: false, - } - } -} - impl PivotGlide { /// Arm the channel with this frame's model-derived target (`None` while the subject has no /// model — hold), step whatever is in flight, and return the height to frame at. /// /// Called every frame, which is the reference's own cadence (`0x50f880` from the driver tail - /// `0x50f011`): the epsilon tests below turn a steady target into a no-op, so "arm per frame" - /// and "arm on change" are the same thing except at the instant the target actually moves. + /// `0x50f011`): the armer's own two epsilon refusals turn a steady target into a no-op, so + /// "arm per frame" and "arm on change" are the same thing except at the instant the target + /// actually moves. pub(super) fn advance(&mut self, target: Option, dt: f32) -> f32 { if let Some(target) = target { - self.arm(target); - } - if let Some((elapsed, dur)) = self.flight.as_mut() { - *elapsed += dt; - let s = *elapsed / *dur; - if s >= 1.0 { - self.live = self.to; - self.flight = None; + if self.seeded { + self.channel.arm(&Arm::at(target, CAM_PIVOT_SMOOTH_SPEED)); } else { - // The reference's kernel `0x5b7bb0` — the same cosine smoothstep the yaw channel - // and the render-scale ease use: `a + (b − a)·(1 − cos(πs))/2`. - let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5; - self.live = self.from + (self.to - self.from) * e; + // The latch: the first height a camera ever sees is established, not travelled to. + self.seeded = true; + self.channel.snap(target); } } - self.live - } - - /// The arming half (`0x5126b0` → `0x512790`), in its own order: the re-arm memo, the - /// already-there test, then the duration — and the once-per-camera snap. - fn arm(&mut self, target: f32) { - if !self.seeded { - // The latch: the first height a camera ever sees is established, not travelled to. - self.seeded = true; - self.live = target; - self.to = target; - self.from = target; - self.flight = None; - return; - } - // Already arming exactly this — a no-op, so a per-frame re-arm cannot restart the move - // from its own midpoint (which would stretch it forever, asymptotically never arriving). - if self.flight.is_some() && (self.to - target).abs() < CAM_PIVOT_EPS { - return; - } - if (self.live - target).abs() < CAM_PIVOT_EPS { - // Already there: park the target and disarm. The steady-state path, every frame. - self.to = target; - self.flight = None; - return; - } - self.from = self.live; - self.to = target; - self.flight = Some((0.0, (target - self.live).abs() / CAM_PIVOT_SMOOTH_SPEED)); + self.channel.advance(dt) } /// What the channel is doing, for `WOW_CAM_DUMP`: `(live, target)`. A pivot question is a /// *timing* question — "does it snap?" is answered by these two columns on a trace, never by /// watching a capture (method: timing is measured, never eyeballed). pub(super) fn probe(&self) -> (f32, f32) { - (self.live, self.to) + self.channel.probe() } } @@ -842,6 +793,24 @@ pub(crate) struct CameraControl { /// body, which is why it glides *through* a change of subject (a shapeshift, a far-sight /// switch) instead of being reset by one. pub(super) pivot: PivotGlide, + /// `cameraPivot`'s pitch-bias channel ([`SmartPivot`], decision 2149) — pose, like the two + /// above it. + pub(super) smart_pivot: SmartPivot, + /// `cameraWaterCollision`'s band-crossing pitch kick ([`WaterPitch`], decision 2149) — also + /// pose: the band it remembers is the *previous frame's*, which is the whole selector. + pub(super) water_pitch: WaterPitch, + /// `cameraTerrainTilt`'s ground-pitch channel and its 100 ms probe throttle ([`TerrainTilt`], + /// decision 2149). + pub(super) terrain_tilt: TerrainTilt, + /// `cameraBobbing`'s session latch and eye offset ([`HeadBob`], decision 2149). + pub(super) head_bob: HeadBob, + /// **Did the collision sweep actually clip the camera on the frame just seated?** The + /// reference's `[cam+0x90] & 0x30000`, which nothing but the solver `0x50e570` writes and the + /// driver ORs in per frame — and which [`SmartPivot`]'s gate reads as its sixth conjunct, so + /// that an unobstructed camera never pivots. Written by [`seat_camera`]; read a frame later by + /// [`run_look_session`], exactly as the reference's input handler reads the flags the last + /// driver pass left (`0x50fee0`'s sole caller `0x514446` precedes the mover lookup). + pub(super) clipped: bool, } impl CameraControl { @@ -1033,6 +1002,10 @@ pub(super) fn run_look_session( left_click: &mut Option, right_click: &mut Option, look_cfg: LookConfig, + // The camera-option knobs + this frame's gate facts (decision 2149). `cameraPivot`'s routing + // lives in the look session because the reference's does: `0x50fee0` IS the mouse-motion + // handler, and the whole decision is "does THIS motion event go into the pitch or the bias". + dynamics: &DynamicsInput, // Seconds on the app clock — the press predicate's two time gates are measured against it. now: f32, ) { @@ -1147,14 +1120,36 @@ pub(super) fn run_look_session( // turns the character (its facing tracks the camera yaw); left-drag leaves the character facing. if let Some(active) = rig.look { let delta = mouse_motion.delta; - cam.yaw -= delta.x * yaw_rate; + let d_yaw = -delta.x * yaw_rate; + cam.yaw += d_yaw; // `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning). let dy = if look_cfg.invert_pitch { -delta.y } else { delta.y }; - cam.pitch = (cam.pitch - dy * pitch_rate).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); + // **The pivot's fork** (`0x50fee0`, decision 2149): a pinned camera looking level-or-up, + // dragged mostly vertically, spends this delta on the view's pitch BIAS and leaves the + // arm alone. `None` is the reference's pure-pivot frame — the integrator does not run. + let d_pitch = -dy * pitch_rate; + if d_pitch != 0.0 { + // **The hand does not cancel the kick — it carries it** (wow-re + // `camera-cvar-kernels.md` Q5). `0x510120` writes all three of the pitch channel's + // fields by the same delta — live `[cam+0xf4]`, start `[cam+0x1e4]` and target + // `[cam+0x1e0]` — so a drag during a `*FinalPitch` ease keeps the ease's *remaining + // travel* instead of losing it. Cancelling was the reading before that answer landed. + rig.water_pitch.nudge(d_pitch); + } + if let Some(d_pitch) = rig.smart_pivot.route_pitch( + d_pitch, + d_yaw, + cam.pitch, + &dynamics.subject, + rig.clipped, + &dynamics.options, + ) { + cam.pitch = (cam.pitch + d_pitch).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); + } if active == LookButton::Right || both_buttons { *face_yaw = cam.yaw; } @@ -1210,6 +1205,8 @@ pub(super) fn seat_on_subject( collide: &benilla_world::collision::WorldCollision<'_, '_>, cam_probe: &Collider, follow: &FollowInput, + dynamics: &DynamicsInput, + world: &benilla_world::world_point::WorldPoint<'_, '_>, ) { // The sweep origin moves with the subject too; rooting it at our own head would cast the boom // across the world and jam it on the first wall in between @@ -1218,6 +1215,28 @@ pub(super) fn seat_on_subject( Some(v) => (v.feet, v.sweep_origin()), None => (feet, head), }; + // The **fourth** substitution, and it belongs here for the same reason the other three do: + // `cameraWaterCollision`'s bands are a fact about the FOLLOWED unit's liquid (`0x511ad0` takes + // the unit, and the room claim is that unit's), so a far-sight subject in a lake is what + // re-bases the pivot, never our own body's puddle. Every liquid, not only water (0634). + let feet_wow = benilla_assets::coords::bevy_to_wow(orbit_pos); + let who = view + .remote + .map_or(benilla_world::world_point::Subject::Player, |v| { + benilla_world::world_point::Subject::Unit(v.entity) + }); + let dynamics = &super::camera_dynamics::DynamicsInput { + liquid: super::camera_dynamics::SubjectLiquid { + // Classified **unconditionally**, as the reference does: `0x511ad0` is called at + // `0x50eb45` whatever `cameraWaterCollision` says, and the CVar gates only whether the + // bands reach the corridor (`0x50e5ec`) and the crossing kick (`0x50ecb9`). The bits + // have a third reader that is NOT gated by it — `0x5103e0`'s `cameraDive` pair — so + // gating the query here would be a trap for whoever builds that. + surface_y: world.liquid_at(who, feet_wow).map(|h| h.surface_z), + feet_y: feet_wow[2], + }, + ..*dynamics + }; // The framing height is the **channel's**, not this frame's target: it eases there over // `|Δh| / 1.2` s with a cosine profile, so a shapeshift, a mount, a growth aura or a far-sight // switch move the camera smoothly instead of teleporting it ([`PivotGlide`]; wow-re @@ -1226,6 +1245,74 @@ pub(super) fn seat_on_subject( let orbit_pivot = rig .pivot .advance(view.remote.map(|v| v.pivot_height).or(body_pivot), dt); + // **`cameraWaterCollision`'s pivot corridor** (decision 2149; wow-re `pivot-height-glide.md` + // §5 + `camera-cvar-gates.md` §4a). The CVar's *first* consumer is `0x50e5ec`, which folds the + // ADT liquid nibble into the solver's sweep class word — but the recorded verdict for the arm + // is unchanged by that (`camera-arm-liquid-blind.md`: the SWEEP stays liquid-blind), and what + // actually moves is the **framing pivot's** floor and cap, re-based by `0x511ad0`'s bands. + // With the CVar off, or out of liquid, `clamp(live, 5/6, live)` is the live height itself. + let (band, d) = dynamics.liquid.band(rig.pivot.probe().1); + let orbit_pivot = if dynamics.options.water_collision { + let (floor, cap) = band.pivot_corridor(d, orbit_pivot); + orbit_pivot.clamp(floor, cap.max(floor)) + } else { + orbit_pivot + }; + // The CVar's other consumer: a band CROSSING arms an absolute pitch through the kick's own + // channel. Run here, before the seat, so the pitch this frame renders at is the eased one — + // and unconditionally, because the previous-frame snapshot it keeps is taken at `0x50eb14`, + // ahead of the CVar test at `0x50ecb9`. + // **`cameraTerrainTilt`'s probe and channel.** The probe looks at the ground AHEAD of the + // subject, not under it: a horizontal ray along its facing from `feet + 5/3`, its hit pulled + // back `5/18`, then a `64/9` drop — so the slope is the rise of the ground you are walking + // ONTO over the run to it. Rooted at the subject, which is what makes far sight tilt to the + // totem's hill rather than to ours. + let ground_probe = || { + let origin = orbit_pos + Vec3::Y * super::camera_dynamics::PROBE_LIFT; + let fwd = Quat::from_rotation_y(dynamics.subject.facing) * Vec3::NEG_Z; + let reach = Dir3::new(fwd) + .ok() + .and_then(|d| collide.ray_body(origin, d, super::camera_dynamics::PROBE_REACH)) + .map_or(super::camera_dynamics::PROBE_REACH, |h| { + h.distance - super::camera_dynamics::PROBE_BACKOFF + }); + let ahead = origin + fwd * reach; + let ground_y = collide + .ray_body(ahead, Dir3::NEG_Y, super::camera_dynamics::PROBE_DROP) + .map_or(ahead.y - super::camera_dynamics::PROBE_DROP, |h| { + ahead.y - h.distance + }); + // `L = √(Δx² + Δy²)` is a length, so a hit closer than the backoff runs the probe + // *behind* the subject and still divides by a positive run — the reference's own + // arithmetic, not a guard added here. + (ground_y - orbit_pos.y) / reach.abs().max(1.0e-3) + }; + rig.terrain_tilt.advance( + ground_probe, + dynamics.options.terrain_tilt, + &dynamics.subject, + dynamics.smooth_style, + &dynamics.options, + dt, + ); + + // **`cameraBobbing`'s latch and kernel.** The session is armed off the input-command word, not + // off this gate, so it runs whatever the CVar says and only the OUTPUT is gated — which is what + // makes turning the CVar on mid-run start the bob at the phase the session has reached. + // `rig.distance` and not `collision_distance`: the reference's first conjunct is on the zoom + // (`[cam+0xec]`), so a camera squeezed against a wall is not thereby in first person. + rig.head_bob + .advance(rig.distance, &dynamics.subject, &dynamics.options, dt); + + // `[cam+0x90] & 1` is FREELOOK — the player holding mouse-look — which picks the HARD SNAP + // over the ease (`0x50ed0e`; wow-re `camera-cvar-kernels.md` Q3). + let freelook = rig.look.is_some(); + if let Some(pitch) = rig + .water_pitch + .advance(band, cam.pitch, freelook, &dynamics.options, dt) + { + cam.pitch = pitch.clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); + } seat_camera( dt, turn_delta, @@ -1238,6 +1325,7 @@ pub(super) fn seat_on_subject( collide, cam_probe, follow, + dynamics, ); } @@ -1267,6 +1355,7 @@ pub(super) fn seat_camera( collide: &benilla_world::collision::WorldCollision<'_, '_>, cam_probe: &Collider, follow: &FollowInput, + dynamics: &DynamicsInput, ) { // A keyboard turn carries the camera RIGIDLY (char and camera rotate as one — the reference // look, director's call closing 0050's open "camera follow on turn"): an eased chase of a @@ -1301,19 +1390,45 @@ pub(super) fn seat_camera( // instead of overshooting (the old min-distance floor used to force the camera *past* a too-close // hit — gone; collision wins outright). `cast_move` ignores origin penetration, so a head grazing // a surface still casts outward. - let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0); + // **The seat is built from the UNBIASED pitch and the view from the biased one** — the whole + // of `cameraPivot` is that ordering (decision 2149). The reference computes the eye at + // `0x50edcc → 0x50de00` and stores it, and only *then* rotates the camera basis `[cam+0x14]` + // by `[cam+0x104]` at `0x50ee32`; so the arm never swings and the look direction does. The + // bias is zero at every default until a pinned camera is dragged, so `arm_rotation` and + // `rotation` are the same quaternion on almost every frame. + let bias = rig.smart_pivot.bias(); + // **The ground tilt IS part of the arm's pitch**, unlike the bias: `0x50f710` composes + // `[cam+0xf4] + [cam+0x108]` and clamps the SUM to ±89° before the basis is built, and the eye + // is then seated from that basis. So a followed terrain moves the camera; a smart pivot does + // not (wow-re `camera-cvar-kernels.md` §1). + let arm_pitch = (cam.pitch + rig.terrain_tilt.pitch()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); + let arm_rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch, 0.0); + // **The composite is deliberately NOT re-clamped to ±89°.** The reference's clamp at + // `0x50f710` binds `[cam+0xf4] + [cam+0x108]` — the pitch channel plus the ground tilt — and + // the bias is composed **after** it, at `0x50ee58`, so the rendered pitch is not bounded by it + // (wow-re `camera-cvar-kernels.md` §1). The bound that does apply to the bias is the one-sided + // one on its own accumulate ([`SmartPivot::route_pitch`]), and the ±89° on the body hand-off + // (`0x5103e0`), which is where `mover_pitch` takes it. + let rotation = if bias == 0.0 { + arm_rotation + } else { + Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch + bias, 0.0) + }; // `Transform::forward()` is exactly `rotation * -Z` (no renormalize), computed here from the // local so the write below can be gated. - let cam_fwd = rotation * Vec3::NEG_Z; + let cam_fwd = arm_rotation * Vec3::NEG_Z; let pivot = player_pos + Vec3::Y * cam_pivot_height; let seat = pivot - cam_fwd * rig.distance; let boom = seat - head; let boom_len = boom.length().max(1.0e-3); // The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes, // drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh. - let open = collide - .cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0) - .map_or(boom_len, |h| h.distance); + let hit = collide.cast_camera(cam_probe, head, Quat::IDENTITY, boom, 0.0); + // The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the + // driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never + // pivots. Written here because here is the only place that knows. + rig.clipped = hit.is_some(); + let open = hit.map_or(boom_len, |h| h.distance); // Snap in instantly when geometry intrudes (a wall must never sit between camera and character); // ease back out to the open arm length once it clears — the vanilla snap-close-then-glide-back. rig.collision_distance = if open < rig.collision_distance { @@ -1323,7 +1438,13 @@ pub(super) fn seat_camera( rig.collision_distance + (open - rig.collision_distance) * t }; let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0); - let translation = head + boom * frac; + let seated = head + boom * frac; + // **The head bob is a pure world-space translation of the eye**, added last, into the same + // slot [`crate::camera_shake`] writes — which is exactly what the reference does (`0x50eb0f` + // folds the bob into the shake's accumulator and `0x50de00` applies the pair once). Zero on + // every frame nothing is bobbing, so the no-op write gate below still holds a parked camera + // bit-stable. + let translation = seated + rig.head_bob.offset(); // The no-op write gate (decision 1362 — 1355's clamp lesson, at the camera): a parked // camera's pose is bit-stable once the collision ease settles, but writing it anyway marked // the camera's transform changed every frame — which re-ran its propagation and told every @@ -1386,11 +1507,24 @@ pub(super) fn seat_camera( ); } + // The pivot bias's own per-frame half (`0x50ed77` → `0x5107f0`): with the gate no longer true + // the bias eases back to zero at `cameraTargetSmoothSpeed`; with it true an ease in flight is + // cancelled where it stands. Runs after the sweep, so `rig.clipped` is this frame's. + rig.smart_pivot.advance( + cam.pitch, + &dynamics.subject, + rig.clipped, + &dynamics.options, + dt, + ); + // Fade the avatar as the camera nears its pivot (zoom-in / a wall pulling the boom in): opaque // in third-person, ramping to invisible in first-person. Keyed off the *realized* camera→pivot // distance (collision-pulled), so backing into a wall also thins you — the faithful behavior. + // Off the SEATED eye, not the bobbed one: the fade is a statement about how far the boom was + // pulled in, and a 5 cm wobble is not that. rig.self_fade_alpha = - self_model_fade_alpha((translation - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW); + self_model_fade_alpha((seated - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW); } /// Apply the self-avatar zoom-in fade ([`CameraControl::self_fade_alpha`], computed in [`control`]) to @@ -1703,6 +1837,7 @@ pub(super) fn fly_free( #[cfg(test)] mod tests { + use super::super::camera_channel::CHANNEL_EPS; use super::*; use benilla_assets::BillboardInfo; use benilla_formats::BillboardKind; @@ -1741,10 +1876,10 @@ mod tests { let expected = (to - from).abs() / CAM_PIVOT_SMOOTH_SPEED; let frames = glide_run(&mut g, Some(to), expected * 2.0); // It arrives, and only at the end. - assert!((frames.last().copied().unwrap() - to).abs() < CAM_PIVOT_EPS); + assert!((frames.last().copied().unwrap() - to).abs() < CHANNEL_EPS); let arrived = frames .iter() - .position(|h| (h - to).abs() < CAM_PIVOT_EPS) + .position(|h| (h - to).abs() < CHANNEL_EPS) .unwrap(); let took = arrived as f32 / 60.0; assert!( @@ -1792,7 +1927,7 @@ mod tests { g.advance(Some(1.0), 1.0 / 60.0); let frames = glide_run(&mut g, Some(2.2), 2.0); assert!( - (frames.last().copied().unwrap() - 2.2).abs() < CAM_PIVOT_EPS, + (frames.last().copied().unwrap() - 2.2).abs() < CHANNEL_EPS, "a per-frame re-arm must still arrive" ); } diff --git a/crates/benilla-app/src/player/camera_channel.rs b/crates/benilla-app/src/player/camera_channel.rs new file mode 100644 index 00000000..6d6e55b6 --- /dev/null +++ b/crates/benilla-app/src/player/camera_channel.rs @@ -0,0 +1,377 @@ +//! **The camera's smoothed-scalar channel** — the one template the reference instantiates four +//! times, in one place instead of four. +//! +//! wow-re `ui/scratch/camera-cvar-gates.md` §8 is the finding this module exists to honour: the +//! camera's pitch, pitch-bias and ground-tilt channels (`0x512830`/`0x512980`, +//! `0x512a50`/`0x512ba0`, `0x512490`/`0x5125e0`) are **one compiler-emitted template** at three +//! sets of field offsets, and `0x5126b0`/`0x512790` — the pivot **height** channel this client +//! already had ([`super::camera::PivotGlide`]) — is a fourth. Their per-frame step is one block of +//! one function (`0x50f160`). +//! +//! | role | live | start | target | start ms | duration | armed bit | rate CVar | +//! |---|---|---|---|---|---|---|---| +//! | pitch | `+0xf4` | `+0x1e4` | `+0x1e0` | `+0x1d8` | `+0x1dc` | `0x2000000` | `cameraPitchSmoothSpeed` `45.0` | +//! | bias | `+0x104` | `+0x1fc` | `+0x1f8` | `+0x1f0` | `+0x1f4` | `0x8000000` | `cameraTargetSmoothSpeed` `90.0` | +//! | ground | `+0x108` | `+0x1b4` | `+0x1b0` | `+0x1a8` | `+0x1ac` | `0x10000000` | `cameraGroundSmoothSpeed` `7.5` | +//! | height | `+0xfc` | `+0x1cc` | `+0x1c8` | `+0x1c0` | `+0x1c4` | `0x20000000` | `cameraHeightSmoothSpeed` `1.2` | +//! +//! The armer's contract, byte-derived and identical at all four: shortest-path-rewrap the live +//! value into `[target − π, target + π]`; **refuse** the request if the channel is already arming +//! the same `{target, delay, factor}` within `0.001`, or if the live value is already within +//! `0.001` of the target; otherwise compute `duration = |target − live| / rate · factor` and hand +//! the tween to the inner. The step is `s = elapsed / duration`, `s ≥ 1` → `live = target`, else +//! the cosine smoothstep `0x5b7bb0` — `a + (b − a)·(1 − cos πs)/2`. +//! +//! **Two things a re-implementation gets wrong.** `+0x1dc`/`+0x1f4`/`+0x1ac`/`+0x1c4` are +//! **durations in seconds**, not rates — the consumer *divides* elapsed seconds by them; and the +//! rate is in the **live value's own units**, so the three angular channels convert their deg/s +//! CVar with `π/180` while the height channel's `1.2` is already yd/s. [`Arm::rate`] is therefore +//! stated in live-units/s and the conversion belongs to the caller, where the unit is known. +//! +//! **The delay is a HOLD here, and the reference's is not** — a named divergence. `0x512830` backs +//! the start time up by `delay` (`now − (int)(p2 · −1000)`), so `0x50f160`'s `s` goes *negative* +//! for that long; and because the profile is `cos(π·s)` — an even function — a negative `s` +//! evaluates the ease as though `|s|`, which makes the live value jump away from `from` at the +//! instant of arming and walk back. That is an unclamped divide, not an intent, and it is +//! unreachable at every default this client reads: `Delay` is `0.0` on all thirty rows of the +//! `cameraTerrainTilt