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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
// `camera<Yaw|Pitch>MoveSpeed · π/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::<LookConfig>()
|
||||
.init_resource::<crate::player::camera_dynamics::CameraOptions>()
|
||||
.init_resource::<crate::ui_chat::combat::CombatLogRanges>()
|
||||
.init_resource::<crate::combat_text::DamageTextGates>()
|
||||
.init_resource::<crate::ui_chat::combat::LogPeriodicSpells>()
|
||||
|
||||
@@ -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 + `<Subname>` (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 — `<AFK>`/`<DND>`/`<GM>` 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 + `<Guild>`. 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 —
|
||||
//! `<AFK>`/`<DND>`/`<GM>` 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<NameConfig>,
|
||||
),
|
||||
mut names: ResMut<NameCache>,
|
||||
// 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<crate::ui_guild::GuildState>,
|
||||
net_commands: Res<NetCommands>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
@@ -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, "<AFK>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("<Legacy>")),
|
||||
(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:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<camera::LookConfig>();
|
||||
app.init_resource::<camera::ZoomLimit>();
|
||||
app.init_resource::<camera::FollowConfig>();
|
||||
app.init_resource::<camera_dynamics::CameraOptions>();
|
||||
// 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
|
||||
|
||||
@@ -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<f32>, 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<PressGesture>,
|
||||
right_click: &mut Option<PressGesture>,
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Style><State>` table, and both the bias and pitch arms pass `0` literally.
|
||||
//! benilla holds at `from` until the delay elapses — [`super::camera::FollowRig`] already does
|
||||
//! exactly that for the one family whose delay is genuinely nonzero (`Track`/`Fear` under Smart,
|
||||
//! `0.4`), so this is the house reading, not a new one.
|
||||
|
||||
/// The channel's "already there / already arming this" epsilon — VERIFIED `0.001` (`[0x801360]`),
|
||||
/// shared by all four instantiations and by the `0x5107f0`/`0x5106f0` displacement predicates.
|
||||
pub(super) const CHANNEL_EPS: f32 = 0.001;
|
||||
|
||||
/// What one `arm` call asks for — the armer's four arguments plus the one caller-side clamp.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Arm {
|
||||
/// Where the channel should end up, in the live value's units.
|
||||
pub(super) target: f32,
|
||||
/// Dead time before the tween starts, seconds (the module doc's named divergence).
|
||||
pub(super) delay: f32,
|
||||
/// Stretches the duration: `duration = |Δ| / rate · factor`. `1.0` is "at the rate".
|
||||
pub(super) factor: f32,
|
||||
/// The channel's rate **in the live value's units per second** — `cvar.to_radians()` for the
|
||||
/// three angular channels, the raw yd/s for the height one.
|
||||
pub(super) rate: f32,
|
||||
/// The duration bound `(min, max)`, when the caller has one. Only the ground channel does:
|
||||
/// `0x50dd29` clamps `[cam+0x1ac]` to `[Factor × cameraTerrainTiltTimeMin, Factor × …TimeMax]`
|
||||
/// *after* the arm, which is why it is the armer's business and not the tween's.
|
||||
pub(super) duration: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Arm {
|
||||
/// An arm with no delay, no stretch and no duration bound — the shape both the pitch
|
||||
/// (`0x512830(target, 0, 1.0f, now)`) and bias (`0x512a50(cam, 0, 0, 1.0f, now)`) sites pass.
|
||||
pub(super) fn at(target: f32, rate: f32) -> Self {
|
||||
Self {
|
||||
target,
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate,
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tween in flight — the reference's `{start, target, startMs, duration}` quadruple plus the
|
||||
/// `{p2, p3}` staleness memo, with the armed bit expressed as `Option<Flight>` rather than a bit.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Flight {
|
||||
/// Seconds since the arm, and the dead time in front of the tween.
|
||||
elapsed: f32,
|
||||
delay: f32,
|
||||
/// The tween's own length, seconds — never a rate.
|
||||
duration: f32,
|
||||
/// What it was armed with (`+0x1ec`/`+0x1e8`), so a repeat asking for the same move is a
|
||||
/// no-op instead of restarting it from its own midpoint.
|
||||
memo: (f32, f32),
|
||||
}
|
||||
|
||||
/// One smoothed scalar channel of the camera — see the module doc.
|
||||
///
|
||||
/// `Default` is the **linear** channel, because the one instantiation whose live value is not an
|
||||
/// angle is the pivot height (yards) — see [`SmoothChannel::angular`].
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct SmoothChannel {
|
||||
live: f32,
|
||||
from: f32,
|
||||
to: f32,
|
||||
flight: Option<Flight>,
|
||||
/// Does the armer's shortest-path rewrap apply? It is a **`2π` rewrap**, so it is meaningful
|
||||
/// only on the three channels whose live value is an angle in radians; running it on the
|
||||
/// height channel would fold yards around a circle.
|
||||
wrap: bool,
|
||||
}
|
||||
|
||||
/// What an [`SmoothChannel::arm`] did — the reference's own three-way return, kept because the
|
||||
/// callers branch on it (`0x512a50` returns 1 for "already arming this", 0 for "already there").
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum Armed {
|
||||
/// A tween was started.
|
||||
Started,
|
||||
/// The channel is already arming this exact request — nothing changed.
|
||||
Already,
|
||||
/// The live value is already within [`CHANNEL_EPS`] of the target — nothing changed, and
|
||||
/// nothing is in flight afterwards either.
|
||||
AtRest,
|
||||
}
|
||||
|
||||
impl SmoothChannel {
|
||||
/// An **angular** channel — pitch, pitch-bias or ground tilt, whose live value is radians and
|
||||
/// whose armer therefore rewraps it into `[target − π, target + π]` (`0x512abe`).
|
||||
pub(super) fn angular() -> Self {
|
||||
Self {
|
||||
wrap: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The value the camera uses this frame.
|
||||
pub(super) fn live(&self) -> f32 {
|
||||
self.live
|
||||
}
|
||||
|
||||
/// Is a tween in flight? (The reference's armed bit of `[cam+0x90]`.)
|
||||
pub(super) fn in_flight(&self) -> bool {
|
||||
self.flight.is_some()
|
||||
}
|
||||
|
||||
/// Establish the channel at `v` with nothing in flight — the reference's **hard snap**, which
|
||||
/// is a real leg of two of the four: the water band-crossing writes `[cam+0xf4]` *and*
|
||||
/// `[cam+0x1e0]` directly when `[cam+0x90] & 1` (`0x50ed13`), and the height channel's first
|
||||
/// arm of a camera's life snaps (`0x5127d4`).
|
||||
pub(super) fn snap(&mut self, v: f32) {
|
||||
self.live = v;
|
||||
self.from = v;
|
||||
self.to = v;
|
||||
self.flight = None;
|
||||
}
|
||||
|
||||
/// The armer (`0x512830`/`0x512a50`/`0x512490`/`0x5126b0`), in its own order.
|
||||
pub(super) fn arm(&mut self, arm: &Arm) -> Armed {
|
||||
// The shortest-path rewrap: the live value is brought into `[target − π, target + π]`
|
||||
// first, so a channel that has accumulated past a half-turn takes the short way round
|
||||
// (`0x512abe fst` — it is stored back, not merely used for the compare).
|
||||
if self.wrap {
|
||||
let two_pi = std::f32::consts::TAU;
|
||||
while self.live - arm.target > std::f32::consts::PI {
|
||||
self.live -= two_pi;
|
||||
}
|
||||
while arm.target - self.live > std::f32::consts::PI {
|
||||
self.live += two_pi;
|
||||
}
|
||||
}
|
||||
if let Some(f) = self.flight {
|
||||
if (self.to - arm.target).abs() < CHANNEL_EPS
|
||||
&& (f.memo.0 - arm.delay).abs() < CHANNEL_EPS
|
||||
&& (f.memo.1 - arm.factor).abs() < CHANNEL_EPS
|
||||
{
|
||||
return Armed::Already;
|
||||
}
|
||||
}
|
||||
let gap = (arm.target - self.live).abs();
|
||||
if gap < CHANNEL_EPS {
|
||||
// Already there: park the target and disarm. This is the steady-state path, and it is
|
||||
// what makes arming every frame identical to arming on change.
|
||||
self.to = arm.target;
|
||||
self.flight = None;
|
||||
return Armed::AtRest;
|
||||
}
|
||||
let mut duration = gap / arm.rate.max(f32::EPSILON) * arm.factor;
|
||||
if let Some((lo, hi)) = arm.duration {
|
||||
duration = duration.clamp(lo, hi);
|
||||
}
|
||||
self.from = self.live;
|
||||
self.to = arm.target;
|
||||
self.flight = Some(Flight {
|
||||
elapsed: 0.0,
|
||||
delay: arm.delay,
|
||||
duration: duration.max(f32::EPSILON),
|
||||
memo: (arm.delay, arm.factor),
|
||||
});
|
||||
Armed::Started
|
||||
}
|
||||
|
||||
/// **Shift the whole channel by `delta`** — live, start and target together, so a tween in
|
||||
/// flight keeps its remaining travel and lands `delta` further along.
|
||||
///
|
||||
/// This is the reference's `0x510120`, which is not a "set the pitch" at all: it adds the same
|
||||
/// delta to `[cam+0xf4]`, `[cam+0x1e4]` and `[cam+0x1e0]` and clamps each. That is what keeps
|
||||
/// the live angle and the tween's endpoints in lockstep, and it is why a mouse drag during a
|
||||
/// `*FinalPitch` ease carries the ease rather than cancelling it
|
||||
/// (wow-re `camera-cvar-kernels.md` Q5).
|
||||
pub(super) fn shift(&mut self, delta: f32) {
|
||||
self.live += delta;
|
||||
self.from += delta;
|
||||
self.to += delta;
|
||||
}
|
||||
|
||||
/// Step whatever is in flight and return the live value — `0x50f160`'s block, shared by all
|
||||
/// four channels.
|
||||
pub(super) fn advance(&mut self, dt: f32) -> f32 {
|
||||
if let Some(f) = self.flight.as_mut() {
|
||||
f.elapsed += dt;
|
||||
let t = f.elapsed - f.delay;
|
||||
if t >= 0.0 {
|
||||
let s = t / f.duration;
|
||||
if s >= 1.0 {
|
||||
self.live = self.to;
|
||||
self.flight = None;
|
||||
} else {
|
||||
// `0x5b7bb0` — the cosine smoothstep, eased at both ends.
|
||||
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
|
||||
self.live = self.from + (self.to - self.from) * e;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.live
|
||||
}
|
||||
|
||||
/// `(live, target)` — what a `WOW_CAM_DUMP` line prints. A channel question is a *timing*
|
||||
/// question, and these two columns are how a trace answers it (method: timing is measured).
|
||||
pub(super) fn probe(&self) -> (f32, f32) {
|
||||
(self.live, self.to)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Step at 60 Hz for `secs` and return every live value it passed through.
|
||||
fn run(c: &mut SmoothChannel, secs: f32) -> Vec<f32> {
|
||||
let dt = 1.0 / 60.0;
|
||||
(0..(secs / dt).round() as usize)
|
||||
.map(|_| c.advance(dt))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The template's duration law is `|Δ| / rate · factor`, and it is a **duration**, not a rate:
|
||||
/// the tween takes exactly that long and the value arrives only at its end.
|
||||
#[test]
|
||||
fn the_duration_is_the_gap_over_the_rate_times_the_factor() {
|
||||
for (gap, rate, factor) in [
|
||||
(10.0_f32, 45.0_f32, 1.0_f32),
|
||||
(30.0, 90.0, 2.0),
|
||||
(1.0, 7.5, 1.0),
|
||||
] {
|
||||
let mut c = SmoothChannel::default();
|
||||
let expected = gap / rate * factor;
|
||||
assert_eq!(
|
||||
c.arm(&Arm {
|
||||
target: gap,
|
||||
delay: 0.0,
|
||||
factor,
|
||||
rate,
|
||||
duration: None
|
||||
}),
|
||||
Armed::Started
|
||||
);
|
||||
let frames = run(&mut c, expected * 2.0);
|
||||
let arrived = frames
|
||||
.iter()
|
||||
.position(|v| (v - gap).abs() < CHANNEL_EPS)
|
||||
.expect("arrives");
|
||||
assert!(
|
||||
(arrived as f32 / 60.0 - expected).abs() < 0.05,
|
||||
"|Δ|/rate·factor = {expected:.3}s, took {:.3}s",
|
||||
arrived as f32 / 60.0
|
||||
);
|
||||
assert!(!c.in_flight(), "and disarms on arrival");
|
||||
}
|
||||
}
|
||||
|
||||
/// Arming every frame with a steady target is exactly arming once — the property the whole
|
||||
/// per-frame-arm design rests on. A memo that only compared the target would still restart the
|
||||
/// tween whenever the *factor* changed, so both are in the compare.
|
||||
#[test]
|
||||
fn a_per_frame_re_arm_neither_restarts_nor_stretches_the_tween() {
|
||||
let mut once = SmoothChannel::default();
|
||||
let mut every = SmoothChannel::default();
|
||||
// Rate 0.5 over a gap of 1.0 = a 2 s tween, so the second below is spent mid-flight.
|
||||
let arm = Arm::at(1.0, 0.5);
|
||||
once.arm(&arm);
|
||||
every.arm(&arm);
|
||||
let dt = 1.0 / 60.0;
|
||||
for _ in 0..60 {
|
||||
assert_eq!(every.arm(&arm), Armed::Already);
|
||||
assert_eq!(every.advance(dt), once.advance(dt));
|
||||
}
|
||||
// …and once it is there, the re-arm reports rest rather than starting a zero move.
|
||||
run(&mut once, 1.5);
|
||||
run(&mut every, 1.5);
|
||||
assert_eq!(every.arm(&arm), Armed::AtRest);
|
||||
assert!(!every.in_flight());
|
||||
}
|
||||
|
||||
/// The rewrap takes the short way round: a channel sitting just under `+π` asked for a target
|
||||
/// just over `−π` travels the small gap, not the long one.
|
||||
#[test]
|
||||
fn the_arm_rewraps_the_live_value_to_the_short_side() {
|
||||
let mut c = SmoothChannel::angular();
|
||||
c.snap(3.1);
|
||||
let target = -3.1_f32;
|
||||
c.arm(&Arm::at(target, 1.0));
|
||||
// The gap the tween covers is the short one (≈0.083), not 6.2.
|
||||
let dur = (target - (3.1 - std::f32::consts::TAU)).abs();
|
||||
let frames = run(&mut c, 1.0);
|
||||
assert!(
|
||||
(frames[frames.len() - 1] - target).abs() < CHANNEL_EPS,
|
||||
"arrives at the target"
|
||||
);
|
||||
assert!(dur < 0.1, "the short way is {dur}");
|
||||
// Nothing in between left the short arc `[3.1 − 2π, −3.1]`.
|
||||
let wrapped = 3.1 - std::f32::consts::TAU;
|
||||
assert!(frames
|
||||
.iter()
|
||||
.all(|v| *v >= wrapped - CHANNEL_EPS && *v <= target + CHANNEL_EPS));
|
||||
// …and a LINEAR channel of the same numbers does not wrap: it travels the long way,
|
||||
// because yards are not radians.
|
||||
let mut linear = SmoothChannel::default();
|
||||
linear.snap(3.1);
|
||||
linear.arm(&Arm::at(target, 1.0));
|
||||
assert!(run(&mut linear, 0.5).iter().any(|v| *v > 0.0));
|
||||
}
|
||||
|
||||
/// The delay holds at the start value and then runs the full tween — the module doc's named
|
||||
/// divergence, pinned so it cannot silently become the reference's negative-`s` jump.
|
||||
#[test]
|
||||
fn a_delay_holds_the_channel_before_the_tween_rather_than_jumping_it() {
|
||||
let mut c = SmoothChannel::default();
|
||||
c.arm(&Arm {
|
||||
target: 1.0,
|
||||
delay: 0.5,
|
||||
factor: 1.0,
|
||||
rate: 1.0,
|
||||
duration: None,
|
||||
});
|
||||
let frames = run(&mut c, 0.45);
|
||||
assert!(frames.iter().all(|v| *v == 0.0), "held for the delay");
|
||||
let frames = run(&mut c, 1.1);
|
||||
assert!((frames[frames.len() - 1] - 1.0).abs() < CHANNEL_EPS);
|
||||
}
|
||||
|
||||
/// The ground channel's own clamp (`0x50dd29`): the duration, not the rate, is bounded.
|
||||
#[test]
|
||||
fn a_duration_bound_clamps_the_tween_not_the_gap() {
|
||||
let mut c = SmoothChannel::default();
|
||||
c.arm(&Arm {
|
||||
target: 100.0,
|
||||
delay: 0.0,
|
||||
factor: 1.0,
|
||||
rate: 7.5,
|
||||
duration: Some((0.1, 0.5)),
|
||||
});
|
||||
let frames = run(&mut c, 0.6);
|
||||
let arrived = frames
|
||||
.iter()
|
||||
.position(|v| (v - 100.0).abs() < CHANNEL_EPS)
|
||||
.expect("arrives");
|
||||
assert!(
|
||||
(arrived as f32 / 60.0 - 0.5).abs() < 0.05,
|
||||
"|Δ|/rate would be 13.3 s; the bound caps it at 0.5"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,8 @@ pub(super) fn control(
|
||||
Res<camera::LookConfig>,
|
||||
Res<camera::ZoomLimit>,
|
||||
Res<camera::FollowConfig>,
|
||||
// The four 1.12 camera option toggles and their numeric siblings (decision 2149).
|
||||
Res<camera_dynamics::CameraOptions>,
|
||||
),
|
||||
// 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
|
||||
@@ -179,7 +181,6 @@ pub(super) fn control(
|
||||
},
|
||||
..*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
|
||||
@@ -204,6 +205,47 @@ pub(super) fn control(
|
||||
follow_command,
|
||||
} = input::look_input(binds, &player, &rig);
|
||||
|
||||
// The camera-option inputs (decision 2149). `translating` is **last frame's** wire word, and
|
||||
// deliberately: the reference's `0x50fee0` runs from the input handler `0x514446`, which
|
||||
// precedes the mover lookup, so it reads the movement flags the previous update left. It is
|
||||
// also all this path can read — `flags::this_frame` runs several hundred lines below, after
|
||||
// the mover.
|
||||
let dynamics = camera_dynamics::DynamicsInput {
|
||||
options: *pointer.4,
|
||||
smooth_style: pointer.3.style,
|
||||
subject: camera_dynamics::SubjectState {
|
||||
move_flags: player.move_flags,
|
||||
// Off the DRIVEN body's descriptor (1277) — a possessed creature's taxi state is what
|
||||
// the camera follows, not ours.
|
||||
taxi: body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(_, _, _, _, _, store, ..)| store)
|
||||
.is_some_and(|s| s.0.unit_flags() & 0x0010_0000 != 0),
|
||||
track: player.server_riding,
|
||||
fear: player.control_lost,
|
||||
facing: player.face_yaw,
|
||||
mounted: body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(_, _, _, _, _, store, ..)| store)
|
||||
.is_some_and(|s| s.0.unit_mount_display_id() > 0),
|
||||
// `GetCurrentSpeed 0x7c4c90` on the mover's own speed set, against LAST frame's flag
|
||||
// word — the same pairing the reference's bob kernel makes, and the only speed either
|
||||
// camera seat below can ask for (the driving path's own is computed hundreds of lines
|
||||
// later, and the stand-down path never computes one at all).
|
||||
speed: body
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|(_, _, _, _, _, _, _, speeds, ..)| speeds)
|
||||
.map_or(0.0, |s| crate::net::current_speed(&s.0, player.move_flags)),
|
||||
command: follow_command,
|
||||
scoped: scoped.active(),
|
||||
},
|
||||
// Resolved inside `seat_on_subject`, which owns the far-sight substitution.
|
||||
liquid: camera_dynamics::SubjectLiquid::default(),
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -316,6 +358,7 @@ pub(super) fn control(
|
||||
left_click,
|
||||
right_click,
|
||||
look_cfg,
|
||||
&dynamics,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
|
||||
@@ -493,6 +536,8 @@ pub(super) fn control(
|
||||
face_yaw: player.face_yaw,
|
||||
command: follow_command,
|
||||
},
|
||||
&dynamics,
|
||||
world,
|
||||
);
|
||||
// Flush a stale run once, so observers stop extrapolating it — but never under a ride,
|
||||
// whose FORWARD report is deliberate and would be cancelled every frame.
|
||||
@@ -817,11 +862,16 @@ pub(super) fn control(
|
||||
// The push is **per mouse-move, not per frame** ([`Player::aim_pitch_seen`]): the ref's
|
||||
// enqueue hangs off the mouse-MOTION event `0x400500cb`, so a still mouse pushes nothing
|
||||
// and the other writers of the field — the wobble, StopSwim's levelling — survive.
|
||||
if mouselook && cam.pitch != player.aim_pitch_seen {
|
||||
player.aim_pitch_seen = cam.pitch;
|
||||
player.mover_pitch = cam
|
||||
.pitch
|
||||
.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
|
||||
//
|
||||
// **The aim is the COMPOSITE, not the camera's own pitch** (decision 2149): the reference's
|
||||
// camera→body hand-off `0x5103e0` clamps `[cam+0x104] + [cam+0xf4]` to ±89° before passing
|
||||
// the pitch on, so a smart-pivot frame — where the arm's pitch does not move and the view's
|
||||
// does — still aims the body. The bias is zero except on those frames, so this reads as
|
||||
// `cam.pitch` everywhere else.
|
||||
let aim_pitch = cam.pitch + rig.smart_pivot.bias();
|
||||
if mouselook && aim_pitch != player.aim_pitch_seen {
|
||||
player.aim_pitch_seen = aim_pitch;
|
||||
player.mover_pitch = aim_pitch.clamp(-MOUSELOOK_PITCH_CLAMP, MOUSELOOK_PITCH_CLAMP);
|
||||
}
|
||||
let mut swim_pitch = 0.0_f32;
|
||||
// The ground height the mover starts this frame at (pre-step feet Y). For a jump this is the
|
||||
@@ -1058,6 +1108,8 @@ pub(super) fn control(
|
||||
&collide,
|
||||
cam_probe,
|
||||
&follow,
|
||||
&dynamics,
|
||||
world,
|
||||
);
|
||||
|
||||
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
|
||||
|
||||
@@ -560,6 +560,29 @@ pub(crate) fn unit_guild(
|
||||
})
|
||||
}
|
||||
|
||||
/// A unit's guild NAME alone — the **a5 line of the overhead name stack** (`"\n<%s>"`,
|
||||
/// `0x860f9c`), which the reference resolves through the very same guild-identity cache this
|
||||
/// serves `GetGuildInfo` from: `0x609085` tests the render mask's bit `0x10`
|
||||
/// (`UnitNamePlayerGuild`) and then reads `0x5e09f0` off the unit's own `[CGUnit+0xe68]+0x8/0xc`
|
||||
/// guild GUID (wow-re `object-layer/scratch/overhead-name.md` Q4 point 3).
|
||||
///
|
||||
/// [`unit_guild`] without the rank — and, deliberately, **without its two `String` clones**: this
|
||||
/// is read once per shown player per frame by [`crate::nameplates::drive_nameplates`], whose whole
|
||||
/// steady-frame design is that a name nothing changed allocates nothing.
|
||||
///
|
||||
/// The `None` legs are [`unit_guild`]'s, unchanged: a guildless player, a creature, and a guild id
|
||||
/// whose `CMSG_GUILD_QUERY` has not answered yet — the last of which is also what *sends* it, so
|
||||
/// the line appears a round trip later and the rebuild arm picks it up on that frame.
|
||||
pub(crate) fn unit_guild_name<'a>(
|
||||
fields: &ObjectFields,
|
||||
guild: &'a mut GuildState,
|
||||
commands: &NetCommands,
|
||||
) -> Option<&'a str> {
|
||||
guild
|
||||
.resolve_identity(fields.player_guild_id(), commands)
|
||||
.map(|identity| identity.name.as_str())
|
||||
}
|
||||
|
||||
/// A unit's guild **tabard**, for the body composite — the emblem five of `SMSG_GUILD_QUERY_RESPONSE`,
|
||||
/// joined off the unit's own PUBLIC `PLAYER_GUILDID` and asking for the identity if we do not hold
|
||||
/// it, exactly like [`unit_guild`].
|
||||
@@ -1207,6 +1230,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The overhead a5 line's reader answers exactly what `GetGuildInfo`'s does, on the same
|
||||
/// three legs and with the same lazy ask — the one thing that could silently drift between
|
||||
/// them is which of the two sends the query, and neither may skip it.
|
||||
#[test]
|
||||
fn unit_guild_name_is_unit_guilds_name_on_every_leg() {
|
||||
let (commands, rx) = net_commands();
|
||||
let mut guild = GuildState::default();
|
||||
guild
|
||||
.identities
|
||||
.insert(7, identity("Legacy", &["GM", "Off"]));
|
||||
// The negative cache: a query that came back empty is "no such guild", not a blank name.
|
||||
guild.identities.insert(8, identity("", &[]));
|
||||
|
||||
let guildless = ObjectFields::from_pairs(&[]);
|
||||
assert_eq!(unit_guild_name(&guildless, &mut guild, &commands), None);
|
||||
assert_eq!(rx.try_iter().count(), 0, "guild id 0 asks nothing");
|
||||
|
||||
let unknown = ObjectFields::from_pairs(&[(191, 9)]);
|
||||
assert_eq!(
|
||||
unit_guild_name(&unknown, &mut guild, &commands),
|
||||
None,
|
||||
"a query in flight draws no line"
|
||||
);
|
||||
assert_eq!(rx.try_iter().count(), 1, "and the miss asked for it");
|
||||
|
||||
let blank = ObjectFields::from_pairs(&[(191, 8)]);
|
||||
assert_eq!(unit_guild_name(&blank, &mut guild, &commands), None);
|
||||
assert_eq!(
|
||||
rx.try_iter().count(),
|
||||
0,
|
||||
"the negative cache re-asks nothing"
|
||||
);
|
||||
|
||||
let member = ObjectFields::from_pairs(&[(191, 7), (192, 1)]);
|
||||
assert_eq!(
|
||||
unit_guild_name(&member, &mut guild, &commands),
|
||||
Some("Legacy")
|
||||
);
|
||||
let via_line = unit_guild_name(&member, &mut guild, &commands).map(str::to_owned);
|
||||
let via_api = unit_guild(&member, &mut guild, &commands).map(|g| g.name);
|
||||
assert_eq!(via_line, via_api, "the two readers of one cache disagree");
|
||||
}
|
||||
|
||||
/// A command channel whose receiver stays alive, so a send neither blocks nor is dropped.
|
||||
fn net_commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
|
||||
@@ -394,6 +394,7 @@ fn verify_splice(
|
||||
s: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
dpi: f32,
|
||||
assets: &mut Option<ResMut<WorldAssets>>,
|
||||
images: &mut Assets<Image>,
|
||||
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
|
||||
@@ -411,6 +412,7 @@ fn verify_splice(
|
||||
s,
|
||||
w,
|
||||
h,
|
||||
dpi,
|
||||
assets,
|
||||
images,
|
||||
font_atlas,
|
||||
@@ -1001,6 +1003,7 @@ pub(super) fn drive_script(
|
||||
s,
|
||||
w,
|
||||
h,
|
||||
dpi,
|
||||
&mut assets,
|
||||
&mut images,
|
||||
&mut font_atlas,
|
||||
@@ -1092,6 +1095,7 @@ pub(super) fn drive_script(
|
||||
s,
|
||||
w,
|
||||
h,
|
||||
dpi,
|
||||
&mut assets,
|
||||
&mut images,
|
||||
&mut font_atlas,
|
||||
@@ -1153,6 +1157,7 @@ pub(super) fn drive_script(
|
||||
s,
|
||||
w,
|
||||
h,
|
||||
dpi,
|
||||
&mut assets,
|
||||
&mut images,
|
||||
&mut font_atlas,
|
||||
@@ -1281,6 +1286,9 @@ fn convert_entry(
|
||||
// particle unit run on the screen diagonal (decision 2013).
|
||||
w: f32,
|
||||
h: f32,
|
||||
// The window's DEVICE scale, for the one arm that resamples its art to physical pixels: the
|
||||
// nameplate border's 0188 sharpen, which is a no-op at any other size.
|
||||
dpi: f32,
|
||||
assets: &mut Option<ResMut<WorldAssets>>,
|
||||
images: &mut Assets<Image>,
|
||||
font_atlas: &mut Option<ResMut<UiFontAtlas>>,
|
||||
@@ -1587,7 +1595,22 @@ fn convert_entry(
|
||||
// generated 128×64 / 128×32 image — white RGB carrying the emblem BLP's own
|
||||
// alpha — into the Texture the setter was handed; the region carries that as
|
||||
// a token path and the resolver builds exactly that image.
|
||||
let resolved = if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
|
||||
let resolved = if p == benilla_ui::script::nameplate::BORDER_TEXTURE {
|
||||
// The V-plate's frame art, resampled to the quad's exact PHYSICAL size
|
||||
// with the sharp kernel instead of GPU-magnified (0188 — the director's
|
||||
// "sharpen the same frame", carried across decision 2148's move of the
|
||||
// plate into the frame system). Keyed by that size, so it re-rasterises on
|
||||
// a resize and is a cache hit on every frame in between. An addon that
|
||||
// blanks the border with `SetTexture("")` never reaches here at all: the
|
||||
// path is gone, and the arm above draws nothing.
|
||||
let px = |v: f32| (v * dpi).round().max(1.0) as u32;
|
||||
a.resampled_sprite(
|
||||
p,
|
||||
(px(rect.width()), px(rect.height())),
|
||||
images,
|
||||
crate::vplates::border::resample_sharp,
|
||||
)
|
||||
} else if let Some(blp) = benilla_ui::script::emblem_mask_path(p) {
|
||||
a.emblem_mask_texture(blp, images)
|
||||
} else if circular {
|
||||
a.portrait_texture(p, images)
|
||||
|
||||
@@ -4395,16 +4395,6 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
|
||||
keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
|
||||
koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
|
||||
),
|
||||
(
|
||||
"UnitNamePlayerGuild",
|
||||
"the `\\n<%s>` guild line in the OVERHEAD name stack — slot a5 of `0x608f50`, gated by bit \
|
||||
`0x10` of the render mask `[0xce8720]`, player branch only, text from `0x5e09f0` through \
|
||||
the WGLD guild-identity cache. NOT the V-key nameplate (`ShouldShowName 0x6070a0` \
|
||||
returns 0 whenever a plate frame is live) and NOT the tooltip (FrameXML's own \
|
||||
`GetGuildInfo`). The data resolves here now — `ui_guild::GuildState` serves \
|
||||
`GetGuildInfo` — so what is missing is the third line in `nameplates::drive_nameplates` \
|
||||
plus its `lines_current` comparator arm and that comparator's differential test",
|
||||
),
|
||||
(
|
||||
"UnitNamePlayerPVPTitle",
|
||||
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
|
||||
@@ -4412,44 +4402,6 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
|
||||
one step further back than its guild twin: the rank byte streams, but the key's second \
|
||||
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
|
||||
),
|
||||
(
|
||||
"cameraTerrainTilt",
|
||||
"the camera's ground-pitch channel. VERIFIED end to end by the §5 round this work \
|
||||
dispatched (wow-re `ui/scratch/camera-cvar-gates.md`): the CVar gates predicate \
|
||||
`0x5105a0`, whose false leg zeroes `[cam+0xa0]` at `0x50d922` and skips both `0x672170` \
|
||||
terrain raycasts; `0x50d900` forms `slope = (Δz + 5/3)/√(Δx²+Δy²)`, walks the 10-entry \
|
||||
slope→pitch table at `0x808a40`, clamps ±20°, throttled to 100 ms. Registered \"0\", so \
|
||||
building it changes nothing until a player asks",
|
||||
),
|
||||
(
|
||||
"cameraBobbing",
|
||||
"head bob. VERIFIED by the same §5 round, including the threshold nobody had read: \
|
||||
`[0x8089ac] = 1/6` is a camera DISTANCE and the compare is inclusive, so head bob is \
|
||||
FIRST-PERSON ONLY. `0x511920` computes `ampH/ampV` from the two amplitude CVars × 1/36, \
|
||||
`speed = clamp(moveSpeed/7.2, [0.5,1.5])`, horizontal at `freq` and vertical at `2×freq`, \
|
||||
into the same eye-translation accumulator as the shake; `0x5106f0` is the DECAY \
|
||||
predicate and fades the residual rather than snapping it. Registered \"0\"",
|
||||
),
|
||||
(
|
||||
"cameraWaterCollision",
|
||||
"two consumers, both VERIFIED by the §5 round. (1) the collision sweep's class mask \
|
||||
becomes `0x1f0171` instead of `0x100171` — the `0xf0000` nibble is the ADT liquid layers \
|
||||
— and `0x511ad0`'s bands re-base the pivot floor/cap. (2) a band CROSSING (not a level \
|
||||
test: `0x50eb14` snapshots the previous frame's bits before `0x511ad0` rewrites them) \
|
||||
arms a 5° pitch through the `+0xf4` channel. Registered \"1\", so this one is ON in the \
|
||||
reference and benilla is the divergence — but the eye/arm verdict of \
|
||||
`camera-arm-liquid-blind.md` still stands: it is the PIVOT that is liquid-aware",
|
||||
),
|
||||
(
|
||||
"cameraPivot",
|
||||
"smart pivot. VERIFIED by the §5 round, which also corrected the ±89° claim: predicate \
|
||||
`0x510690` is `enabled ∧ TYPEMASK_UNIT ∧ not moving/strafing (`&0xf`, so turning in place \
|
||||
passes) ∧ pitched down ∧ the collision solver's own clip flags `0x30000` nonzero` — i.e. \
|
||||
it only engages in a frame the camera was ACTUALLY clipped. A mostly-vertical drag then \
|
||||
routes into the pitch-BIAS channel `cam+0x104` instead of the pitch integrator, with a \
|
||||
one-sided floor, and `0x5107f0` eases it back at `cameraTargetSmoothSpeed` when the \
|
||||
predicate drops. Registered \"1\"",
|
||||
),
|
||||
// **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from
|
||||
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row —
|
||||
// `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic:
|
||||
|
||||
@@ -216,6 +216,11 @@ mod tests {
|
||||
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
|
||||
// which is the same guarantee arrived at the other way.
|
||||
("ui_script/extract/mod.rs", "last_seam"),
|
||||
// The plate driver's anti-overlap scratch (decision 2148 put the VM in that system's
|
||||
// hands). It is cleared at the top of every run and rebuilt from this frame's plates —
|
||||
// a reused allocation, not memory: nothing in it survives the call it was filled in, so
|
||||
// a new VM has nothing to stale.
|
||||
("vplates.rs", "bucket"),
|
||||
// The window's `scale_factor` beside it (decision 1342) — the other term a measure is
|
||||
// only correct under, since a logical height becomes an integer DEVICE-pixel raster size.
|
||||
// A fact about the window, not about the VM; it gates the same re-seat `last_seam` does,
|
||||
|
||||
+112
-379
@@ -79,20 +79,17 @@ use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
use benilla_ui::script::{unit_is_grey, JustifyH, JustifyV, Outline};
|
||||
use benilla_ui::script::{unit_is_grey, PlateGeometry, PlateState};
|
||||
|
||||
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::{ring_reaction, Factions, Hovered, Selection, TargetUpdate};
|
||||
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
|
||||
use benilla_assets::{AssetSet, WorldAssets};
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
/// Sharp-resampling the frame border ([`border::resample_sharp`]) so the 128×32 art reads crisp at
|
||||
/// the plate's larger size instead of bilinear-magnified soft (0188).
|
||||
mod border;
|
||||
pub(crate) mod border;
|
||||
|
||||
/// The master bitmask (`[0xc4da34]`): bit 0 enemy plates, bit 3 friendly. **Both boot OFF, the
|
||||
/// real client's own state** — see the derivation below. Enemy plates booted ON here from the 0167
|
||||
@@ -144,13 +141,6 @@ pub(crate) struct VPlates(pub(crate) EntityHashSet);
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PlateRects(pub(crate) Vec<(Rect, Entity)>);
|
||||
|
||||
/// The plate's textures — its own art, chain-verified present (§7 draw list). The name
|
||||
/// `NAMEPLATE_FONT` resolves to Friz Quadrata, our atlas default.
|
||||
const BAR_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
|
||||
const BORDER_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Border";
|
||||
const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Skull";
|
||||
const RAID_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||||
|
||||
/// The plate frame, gx screen-height units (`[0x87d9cc]`/`[0x87d9d0]`): 0.1 × 0.025. The border
|
||||
/// SetAllPoints-fills it; everything else anchors inside it (§7, byte-verified offsets).
|
||||
const PLATE_W: f32 = 0.1;
|
||||
@@ -184,25 +174,6 @@ const MAX_DIST_SQ: f32 = 20.0 * 20.0;
|
||||
/// (2026-07-07) from the faithful `0x7F` (0.5), which faded the other plates too hard; raised so
|
||||
/// non-target plates stay readable. Tunable: 0.5 = the byte law, 1.0 = no dim.
|
||||
const DIM_ALPHA: f32 = 178.0 / 255.0;
|
||||
/// The LIT (mouseover ∪ target) bar brighten — a uniform multiplicative lift of the fill tint
|
||||
/// (director-pinned form: brighter colour, gradient untouched). Quad colors are client-space
|
||||
/// sRGB (`srgb_quad_color` linearizes them), so this multiplies ENCODED texels ~1:1 — the
|
||||
/// gamma-space modulate the client's FFP would do. 255/215 is the largest clean value: the
|
||||
/// fill texture's encoded peak (215) lands exactly on white, no row clips, every row scales
|
||||
/// by the same visible factor. (A first cut of 1.45 assumed a linear-space multiply and
|
||||
/// flattened six rows against white — the very gradient change the director banned.)
|
||||
const LIT_BOOST: f32 = 255.0 / 215.0;
|
||||
/// The plate quads' z keys — back→front: bar fill, then the border OVER it (the border art's
|
||||
/// rounded inner bevels cap the fill's square ends and crop the gradient's soft edges — the
|
||||
/// reference look, director-pinned from a ref crop 2026-07-07; corrects the §7 transcription's
|
||||
/// border-first order), then texts → skull; all above the floating combat text and the chat
|
||||
/// bubbles (the append lane's lower bands, [`crate::ui_pass::overlay_z`]) and far below every
|
||||
/// scripted-UI packed key.
|
||||
const Z_FILL: u64 = overlay_z::VPLATE;
|
||||
const Z_BORDER: u64 = overlay_z::VPLATE + 1;
|
||||
const Z_TEXT: u64 = overlay_z::VPLATE + 2;
|
||||
const Z_RAID: u64 = overlay_z::VPLATE + 3;
|
||||
const Z_SKULL: u64 = overlay_z::VPLATE + 4;
|
||||
|
||||
/// The bar-fill palette — the byte-confirmed dwords (`0xcf60d0/e8/c8/dc`): pure
|
||||
/// red/blue/yellow/green (NOT the ring's pale player-blue). Client-space sRGB, the [`UiQuads`]
|
||||
@@ -461,69 +432,6 @@ struct PlateWorld<'w, 's> {
|
||||
window: Query<'w, 's, &'static Window, With<bevy::window::PrimaryWindow>>,
|
||||
}
|
||||
|
||||
/// The plate bitmap art, loaded once at STARTUP (not lazily on the first shown frame — the
|
||||
/// director's first-toggle screenshot caught plates drawing before their textures had decoded).
|
||||
/// The frame border is NOT here — it lives in [`PlateBorder`], resampled per size.
|
||||
#[derive(Resource)]
|
||||
struct PlateArt {
|
||||
fill: Handle<Image>,
|
||||
skull: Handle<Image>,
|
||||
raid_icons: Handle<Image>,
|
||||
}
|
||||
|
||||
/// The frame border: the decoded `Nameplate-Border` BLP (`src`, the exact 128×32 art) plus the
|
||||
/// sharpened texture cached at the plate's current physical size. Blitted at the plate's larger
|
||||
/// size the GPU bilinear-magnifies the BLP into a soft blur, so instead we resample the SAME pixels
|
||||
/// to the display size with sharp bilinear ([`border::resample_sharp`]) — crisp edges, same art
|
||||
/// (0188, the director's "sharpen the same frame"). Redrawn only when the size changes.
|
||||
#[derive(Resource)]
|
||||
struct PlateBorder {
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
src: Vec<u8>,
|
||||
size: Option<(u32, u32)>,
|
||||
handle: Option<Handle<Image>>,
|
||||
}
|
||||
|
||||
/// Warm the plate textures at boot so the first V press draws complete plates. Through
|
||||
/// [`WorldAssets::sprite_texture`] — the player-UI decode (**sRGB**, clamp, mip 0), the format the
|
||||
/// UI pass's linearize-multiply-reencode contract requires. The raw `asset_server.load` this
|
||||
/// shipped with took the `WorldArt` default (gamma-space `Unorm`, repeat): every plate texel got
|
||||
/// sampled as linear and re-encoded brighter — the director's washed-out lime-instead-of-gold.
|
||||
fn load_plate_art(
|
||||
mut commands: Commands,
|
||||
assets: Option<ResMut<WorldAssets>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
) {
|
||||
let Some(mut assets) = assets else {
|
||||
return; // no game data (bare test app) — drive_vplates tolerates the missing resource
|
||||
};
|
||||
let fill = assets.sprite_texture(BAR_TEXTURE, &mut images);
|
||||
let skull = assets.sprite_texture(SKULL_TEXTURE, &mut images);
|
||||
let raid_icons = assets.sprite_texture(RAID_TEXTURE, &mut images);
|
||||
let (Some(fill), Some(skull), Some(raid_icons)) = (fill, skull, raid_icons) else {
|
||||
warn!("vplates: plate art missing from the patch chain — plates will not draw");
|
||||
return;
|
||||
};
|
||||
// Decode the border BLP's raw pixels once; drive_vplates resamples them per plate size.
|
||||
let Some((src_w, src_h, src)) = assets.decode_rgba(BORDER_TEXTURE) else {
|
||||
warn!("vplates: border art missing from the patch chain — plates will not draw");
|
||||
return;
|
||||
};
|
||||
commands.insert_resource(PlateArt {
|
||||
fill,
|
||||
skull,
|
||||
raid_icons,
|
||||
});
|
||||
commands.insert_resource(PlateBorder {
|
||||
src_w,
|
||||
src_h,
|
||||
src,
|
||||
size: None,
|
||||
handle: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Gate + draw, every frame: decide which units carry a plate (into [`VPlates`], the
|
||||
/// name-exclusivity verdict) and append the §7 draw list — border, left-cropped reaction fill,
|
||||
/// shadowed name (yellow under the mouse), con-colored level or the skull — at constant
|
||||
@@ -537,12 +445,11 @@ fn drive_vplates(
|
||||
world: PlateWorld,
|
||||
mut names: ResMut<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
mut atlas: Option<ResMut<UiFontAtlas>>,
|
||||
mut quads: ResMut<UiQuads>,
|
||||
art: Option<Res<PlateArt>>,
|
||||
// The border source + size-keyed cache, and the image store it resamples into.
|
||||
plate_border: Option<ResMut<PlateBorder>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
// The widget layer this drives (decision 2148). `None` in a VM-less run (a capture with the
|
||||
// interface off, a bare test app) — the gate below then costs one early return.
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
// The seam this frame's px↔unit conversion runs at (`windowH/768 × uiScale`).
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
// The overhead-anchor inputs ([`overhead_anchor`]).
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
@@ -563,13 +470,9 @@ fn drive_vplates(
|
||||
if !mode.enemies && !mode.friends {
|
||||
return;
|
||||
}
|
||||
let (Ok((cam, cam_pose)), Ok((self_tf, self_store)), Some(atlas), Some(art), Some(mut border)) = (
|
||||
world.camera.single(),
|
||||
world.self_q.single(),
|
||||
atlas.as_mut(),
|
||||
art.as_deref(),
|
||||
plate_border,
|
||||
) else {
|
||||
let (Ok((cam, cam_pose)), Ok((self_tf, self_store)), Some(mut script)) =
|
||||
(world.camera.single(), world.self_q.single(), script)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let cam_tf = GlobalTransform::from(*cam_pose);
|
||||
@@ -583,24 +486,15 @@ fn drive_vplates(
|
||||
let my_level = self_store.and_then(|s| s.0.unit_level()).unwrap_or(1);
|
||||
let has_target = world.selection.target.is_some();
|
||||
|
||||
// The plate frame is constant-size this frame; resample the 128×32 border BLP to its exact
|
||||
// PHYSICAL pixel size (logical × scale_factor — like the glyph atlas) with sharp bilinear, and
|
||||
// cache it. Redrawn only when that size changes; drawn 1:1 so the SAME art reads crisp instead
|
||||
// of GPU-bilinear-magnified soft (0188 — the director's "sharpen the same frame").
|
||||
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
|
||||
let scale = window.map_or(1.0, |w| w.scale_factor());
|
||||
let tex = (
|
||||
(pw * scale).round().max(1.0) as u32,
|
||||
(ph * scale).round().max(1.0) as u32,
|
||||
);
|
||||
if border.size != Some(tex) {
|
||||
let rgba = border::resample_sharp(&border.src, border.src_w, border.src_h, tex.0, tex.1);
|
||||
border.handle = Some(images.add(benilla_assets::sprite_image(tex.0, tex.1, rgba)));
|
||||
border.size = Some(tex);
|
||||
}
|
||||
let Some(border_tex) = border.handle.clone() else {
|
||||
return;
|
||||
};
|
||||
// The px↔unit seam. Every number below is computed in window px exactly as it always was —
|
||||
// the projection, the seat, the solve, the device snap — and divided through this once, at
|
||||
// the boundary, because the widget layer speaks FrameXML units.
|
||||
let seam = window.map_or(1.0, |w| {
|
||||
crate::ui_script::seam_scale(w.height(), ui_scale.0)
|
||||
});
|
||||
let mut states: Vec<PlateState> = Vec::new();
|
||||
|
||||
// The seat PRIORITY (`0x608870`/`0x6089a0` + the per-frame walk `0x608ce0`): plates seat in
|
||||
// squared-distance order from the fixed gx point (0.4, 0.3) — the 4:3 screen center, a
|
||||
@@ -741,10 +635,6 @@ fn drive_vplates(
|
||||
};
|
||||
// The bar tint: the NAMEPLATE palette in `0x7cbaa0`'s exact order.
|
||||
let tint = plate_tint(rank, is_player);
|
||||
let frac = store
|
||||
.and_then(|s| Some(s.0.unit_health()? as f32 / s.0.unit_max_health()?.max(1) as f32))
|
||||
.unwrap_or(1.0)
|
||||
.clamp(0.0, 1.0);
|
||||
// The frame SEAT (§8 Q5, byte-confirmed): the plate's TOP-CENTER lands on the projected
|
||||
// point — the plate HANGS BELOW head + 2/3 yd — and the point is edge-clamped half a
|
||||
// plate inside every screen border (`SetPoint(TOP ← root.BOTTOMLEFT, clampedX/Y)`).
|
||||
@@ -823,258 +713,91 @@ fn drive_vplates(
|
||||
screen.x, screen.y, plate.min.x, plate.min.y, plate.max.x, plate.max.y
|
||||
);
|
||||
}
|
||||
// The border, drawn OVER the fill (z 5 > z 4): its rounded inner bevels cap the fill's
|
||||
// square ends and crop the gradient's soft top/bottom rows — the reference look
|
||||
// (director-pinned from a ref crop; corrects the §7 transcription's border-first order).
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: plate,
|
||||
z_key: Z_BORDER,
|
||||
texture: Some(border_tex.clone()),
|
||||
uv: UvRect::FULL,
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
..default()
|
||||
});
|
||||
// The health fill, bottommost — a left-anchored CROP (quad right = left + frac·width,
|
||||
// u1 = frac; byte-verified `SetValue → 0x770410`), reaction-tinted, instant. No backing
|
||||
// quad — the border alone sits behind missing health.
|
||||
let bar_left = plate.min.x + gx(BAR_OFF_X);
|
||||
let bar_bottom = plate.max.y - gx(BAR_OFF_Y);
|
||||
let bar = Rect::new(
|
||||
bar_left,
|
||||
bar_bottom - gx(BAR_H),
|
||||
bar_left + gx(BAR_W),
|
||||
bar_bottom,
|
||||
);
|
||||
if trace {
|
||||
eprintln!(
|
||||
"vplate-trace: fill=({:.1},{:.1})..({:.1},{:.1}) frac={frac:.3}",
|
||||
bar.min.x, bar.min.y, bar.max.x, bar.max.y
|
||||
);
|
||||
}
|
||||
// The highlight: LIT (mouseover ∪ target), the bar's own colour simply brightens — a
|
||||
// uniform [`LIT_BOOST`] lift of the fill tint, nothing else changes (director-pinned
|
||||
// 2026-07-07: no rim, no gradient change — the additive Nameplate-Glow rim we tried
|
||||
// first read as hard edge lines, see 0184).
|
||||
let boost = if lit { LIT_BOOST } else { 1.0 };
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: Rect::new(
|
||||
bar.min.x,
|
||||
bar.min.y,
|
||||
bar.min.x + bar.width() * frac,
|
||||
bar.max.y,
|
||||
),
|
||||
z_key: Z_FILL,
|
||||
texture: Some(art.fill.clone()),
|
||||
uv: UvRect {
|
||||
corners: [[0.0, 0.0], [frac, 0.0], [frac, 1.0], [0.0, 1.0]],
|
||||
},
|
||||
color: [tint[0] * boost, tint[1] * boost, tint[2] * boost, alpha],
|
||||
..default()
|
||||
});
|
||||
// Director-tuned (2026-07-07): a constant 1 logical px. The recorded ±0.001 gx rounds
|
||||
// to 1 px at the ref's 1152×648 but 2 px at larger windows — which read chunky.
|
||||
let shadow = 1.0;
|
||||
// The name — its BOTTOM at the plate CENTER, white (yellow while hovered, never
|
||||
// reaction-colored), with the FontString's black drop shadow.
|
||||
if let Some(name) = names.resolve(guid.0, &net_commands).map(str::to_owned) {
|
||||
let color = if hover {
|
||||
[1.0, 1.0, 0.0, alpha]
|
||||
} else {
|
||||
[1.0, 1.0, 1.0, alpha]
|
||||
};
|
||||
plate_text(
|
||||
atlas,
|
||||
&mut quads,
|
||||
&name,
|
||||
Vec2::new(
|
||||
(plate.min.x + plate.max.x) * 0.5,
|
||||
(plate.min.y + plate.max.y) * 0.5,
|
||||
),
|
||||
true,
|
||||
text_px(NAME_H, basis),
|
||||
shadow,
|
||||
color,
|
||||
trace,
|
||||
);
|
||||
}
|
||||
// The level element — CENTER at plate BOTTOMRIGHT + (−0.0092, +0.0071): the skull
|
||||
// replaces the number for a hostile ≥ 10 levels up that isn't trivial-gray (the
|
||||
// world-boss rank leg waits on caching the query's rank field; the exact trivial
|
||||
// predicate `0x5f0700` is INFERRED — we use the con gray).
|
||||
let lvl_at = Vec2::new(plate.max.x - gx(LEVEL_OFF_X), plate.max.y - gx(LEVEL_OFF_Y));
|
||||
if let Some(level) = store.and_then(|s| s.0.unit_level()) {
|
||||
let con = con_color(my_level, level);
|
||||
// The skull's two legs (`0x7cbb40`, §7-VERIFIED): a WORLD BOSS — creature-
|
||||
// classification rank 3, from the ask-once creature record (it lands with the
|
||||
// name query; a miss just means no skull yet) — unconditionally; or a hostile
|
||||
// ≥ 10 levels up that isn't trivial-grey (`0x5f0700`, the shared grey check).
|
||||
// The rank goes through the client's own getter (`gated_rank`, decision 0782), which is
|
||||
// what makes a MIND-CONTROLLED world boss show its NUMBER here rather than a skull:
|
||||
// `0x7cbb40` reads rank via `0x605620`, pet gate and all.
|
||||
let world_boss = crate::names::gated_rank(
|
||||
benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_record(e)),
|
||||
store,
|
||||
) == 3;
|
||||
if world_boss || (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level))
|
||||
{
|
||||
let half = gx(SKULL_SIZE) * 0.5;
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: Rect::new(
|
||||
lvl_at.x - half,
|
||||
lvl_at.y - half,
|
||||
lvl_at.x + half,
|
||||
lvl_at.y + half,
|
||||
),
|
||||
z_key: Z_SKULL,
|
||||
texture: Some(art.skull.clone()),
|
||||
uv: UvRect::FULL,
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
..default()
|
||||
});
|
||||
} else {
|
||||
let mut c = con;
|
||||
c[3] *= alpha;
|
||||
plate_text(
|
||||
atlas,
|
||||
&mut quads,
|
||||
&level.to_string(),
|
||||
lvl_at,
|
||||
false,
|
||||
text_px(LEVEL_H, basis),
|
||||
shadow,
|
||||
c,
|
||||
trace,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The raid icon (vkey §7 + "Raid icon", VERIFIED — the 0434 §6 board finally streams):
|
||||
// shown iff the unit's guid holds a mark; RIGHT ← border.LEFT (0,0) so it hangs off the
|
||||
// plate's left edge, 0.02×0.02, the 4-column atlas cell (col=idx&3, row=idx>>2, cell
|
||||
// 0.25). Drawn between the level and the skull (the §7 draw order).
|
||||
// ── The plate's state, handed to the widget layer ─────────────────────────────────
|
||||
//
|
||||
// Everything above is unchanged: the gate, the anchor, the projection's ACCEPT verdict,
|
||||
// the distance-sorted seat and the bucket solve are all about the WORLD, and they stay
|
||||
// the app's. What changed in decision 2148 is only what happens with the answer — it used
|
||||
// to become six quads, and it now becomes one [`PlateState`] for a real widget, which the
|
||||
// shared frame→quad path draws and an addon can read, hook, restyle or take over.
|
||||
//
|
||||
// The seam scale (`windowH/768 × uiScale`) converts our window px into the FrameXML units
|
||||
// the widget layer speaks. Dividing it out here is deliberate and is a DIVERGENCE worth
|
||||
// naming: the reference's plates are outside the `uiScale` cascade (uiScale is
|
||||
// `UIParent`'s own frame scale there, and the WorldFrame is a sibling root), while
|
||||
// benilla folds uiScale into one global seam (0582/0584). Until that seam grows a
|
||||
// per-cascade answer, the driver compensates, so a plate's PIXELS stay uiScale-blind the
|
||||
// way the reference's are.
|
||||
// The raid-target board (decision 0434 §6): 1-based here, 0-based for the atlas cell.
|
||||
let mark = group.raid_target_index(guid.0);
|
||||
if mark >= 1 {
|
||||
let i = u32::from(mark - 1);
|
||||
let (u0, v0) = ((i & 3) as f32 * 0.25, (i >> 2) as f32 * 0.25);
|
||||
let size = gx(RAID_ICON_SIZE);
|
||||
let cy = (plate.min.y + plate.max.y) * 0.5;
|
||||
quads.overlays.push(UiQuad {
|
||||
rect: Rect::new(
|
||||
plate.min.x - size,
|
||||
cy - size * 0.5,
|
||||
plate.min.x,
|
||||
cy + size * 0.5,
|
||||
),
|
||||
z_key: Z_RAID,
|
||||
texture: Some(art.raid_icons.clone()),
|
||||
uv: UvRect {
|
||||
corners: [
|
||||
[u0, v0],
|
||||
[u0 + 0.25, v0],
|
||||
[u0 + 0.25, v0 + 0.25],
|
||||
[u0, v0 + 0.25],
|
||||
],
|
||||
},
|
||||
color: [1.0, 1.0, 1.0, alpha],
|
||||
..default()
|
||||
});
|
||||
}
|
||||
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
|
||||
let y_units = (viewport.y - plate.min.y) / seam;
|
||||
states.push(PlateState {
|
||||
// The plate's identity is the UNIT's, for the life of the plate — the reference's
|
||||
// `[unit+0xe60]` binding, which every addon's per-plate cache rests on.
|
||||
key: guid.0,
|
||||
top_centre: (x_units, y_units),
|
||||
// RAW health and its max — `healthbar:GetValue()` is `[bar+0x320]`, not a fraction
|
||||
// (`nameplate-lua-surface.md` Q5, which corrected wow-re's own note). pfUI and
|
||||
// CustomNameplates both divide, and a fraction here would read as full health.
|
||||
health: store.and_then(|s| s.0.unit_health()).unwrap_or(0) as f32,
|
||||
max_health: store
|
||||
.and_then(|s| s.0.unit_max_health())
|
||||
.unwrap_or(1)
|
||||
.max(1) as f32,
|
||||
bar_colour: [tint[0], tint[1], tint[2]],
|
||||
name: names
|
||||
.resolve(guid.0, &net_commands)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default(),
|
||||
// `None` = the skull takes the level's seat. Its two legs (`0x7cbb40`, §7-VERIFIED):
|
||||
// a WORLD BOSS — creature-classification rank 3, through the client's own getter
|
||||
// (`gated_rank`, decision 0782, which is why a MIND-CONTROLLED boss shows its number)
|
||||
// — unconditionally; or a hostile ≥ 10 levels up that isn't trivial-grey (`0x5f0700`,
|
||||
// the shared check, vacuous on a ≥ +10 hostile and kept for transcription fidelity).
|
||||
level: store.and_then(|s| s.0.unit_level()).and_then(|level| {
|
||||
let world_boss = crate::names::gated_rank(
|
||||
benilla_protocol::guid::entry(guid.0).and_then(|e| names.creature_record(e)),
|
||||
store,
|
||||
) == 3;
|
||||
let skull = world_boss
|
||||
|| (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level));
|
||||
(!skull).then_some(level)
|
||||
}),
|
||||
level_colour: {
|
||||
let c = store
|
||||
.and_then(|s| s.0.unit_level())
|
||||
.map_or([1.0, 1.0, 1.0, 1.0], |level| con_color(my_level, level));
|
||||
[c[0], c[1], c[2]]
|
||||
},
|
||||
// The 0434 §6 board, 0-based for the atlas (`col = idx & 3`, `row = idx >> 2`).
|
||||
raid_icon: (mark >= 1).then(|| mark - 1),
|
||||
alpha,
|
||||
lit,
|
||||
hovered: hover,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of plate text at `anchor` — ink centered on `anchor.x`; `bottom_seated` puts the
|
||||
/// ink's bottom edge on `anchor.y` (the name's BOTTOM anchor), else its ink middle (the level's
|
||||
/// CENTER) — with the plate FontString's black drop shadow one [`SHADOW_OFF`] right+down.
|
||||
/// Both seats are computed from the **measured ink**, never the layout's line box: cosmic-text's
|
||||
/// `Middle` centers the em box (ascent-heavy for Friz), which sat the level digit ~8 px below its
|
||||
/// anchor — half outside the plate, the director's "level is not aligned".
|
||||
/// `NAMEPLATE_FONT` resolves to Friz, the atlas default.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plate_text(
|
||||
atlas: &mut UiFontAtlas,
|
||||
quads: &mut UiQuads,
|
||||
text: &str,
|
||||
anchor: Vec2,
|
||||
bottom_seated: bool,
|
||||
px: f32,
|
||||
shadow_px: f32,
|
||||
color: [f32; 4],
|
||||
trace: bool,
|
||||
) {
|
||||
// Laid out AT the target em. Plate ems are window-derived (8 at 768, 11 at 1080,
|
||||
// [`text_px`]) and so landed on almost nothing in the old fixed size ladder: every plate on
|
||||
// screen used to be shaped at the nearest rung and rescaled about the anchor. Since decision
|
||||
// 1342 the raster follows the request, so the rescale — and its sub-pixel scatter — is gone.
|
||||
let mut e = atlas.lock();
|
||||
let spec = FontSpec {
|
||||
path: None,
|
||||
height: Some(px),
|
||||
outline: Outline::None,
|
||||
alpha_gradient: None,
|
||||
};
|
||||
let justify = Justify {
|
||||
h: JustifyH::Center,
|
||||
v: JustifyV::Middle,
|
||||
};
|
||||
let mut main = layout_text_quads(
|
||||
&mut e,
|
||||
text,
|
||||
Rect::from_center_size(anchor, Vec2::ZERO),
|
||||
color,
|
||||
justify,
|
||||
Z_TEXT,
|
||||
spec,
|
||||
// The window-derived half, once per frame — the widget layer rewrites the static anchors only
|
||||
// when this actually moves, which is what keeps an addon's own re-anchoring alive between
|
||||
// resizes (decision 2148 §5).
|
||||
script.sync_nameplates(
|
||||
PlateGeometry {
|
||||
width: pw / seam,
|
||||
height: ph / seam,
|
||||
bar_off_x: gx(BAR_OFF_X) / seam,
|
||||
bar_off_y: gx(BAR_OFF_Y) / seam,
|
||||
bar_width: gx(BAR_W) / seam,
|
||||
bar_height: gx(BAR_H) / seam,
|
||||
level_off_x: gx(LEVEL_OFF_X) / seam,
|
||||
level_off_y: gx(LEVEL_OFF_Y) / seam,
|
||||
skull_size: gx(SKULL_SIZE) / seam,
|
||||
raid_size: gx(RAID_ICON_SIZE) / seam,
|
||||
name_height: text_px(NAME_H, basis) / seam,
|
||||
level_height: text_px(LEVEL_H, basis) / seam,
|
||||
},
|
||||
&states,
|
||||
);
|
||||
drop(e);
|
||||
if main.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Seat by measured INK, then the shadow is the same run offset one step right+down.
|
||||
let dy = {
|
||||
let bottom = main.iter().map(|q| q.rect.max.y).fold(f32::MIN, f32::max);
|
||||
if bottom_seated {
|
||||
anchor.y - bottom
|
||||
} else {
|
||||
let top = main.iter().map(|q| q.rect.min.y).fold(f32::MAX, f32::min);
|
||||
anchor.y - (top + bottom) * 0.5
|
||||
}
|
||||
};
|
||||
for q in main.iter_mut() {
|
||||
q.rect = Rect::new(
|
||||
q.rect.min.x,
|
||||
q.rect.min.y + dy,
|
||||
q.rect.max.x,
|
||||
q.rect.max.y + dy,
|
||||
);
|
||||
}
|
||||
let mut shadow: Vec<UiQuad> = main
|
||||
.iter()
|
||||
.map(|q| UiQuad {
|
||||
rect: Rect::new(
|
||||
q.rect.min.x + shadow_px,
|
||||
q.rect.min.y + shadow_px,
|
||||
q.rect.max.x + shadow_px,
|
||||
q.rect.max.y + shadow_px,
|
||||
),
|
||||
color: [0.0, 0.0, 0.0, color[3]],
|
||||
..q.clone()
|
||||
})
|
||||
.collect();
|
||||
if trace {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for q in main.iter() {
|
||||
x0 = x0.min(q.rect.min.x);
|
||||
y0 = y0.min(q.rect.min.y);
|
||||
x1 = x1.max(q.rect.max.x);
|
||||
y1 = y1.max(q.rect.max.y);
|
||||
}
|
||||
eprintln!(
|
||||
"vplate-trace: text {text:?} px={px:.1} ink=({x0:.1},{y0:.1})..({x1:.1},{y1:.1}) anchor=({:.1},{:.1})",
|
||||
anchor.x, anchor.y
|
||||
);
|
||||
}
|
||||
// Shadow first, main over it (equal z resolves by push order — the stable sort).
|
||||
quads.overlays.append(&mut shadow);
|
||||
quads.overlays.append(&mut main);
|
||||
}
|
||||
|
||||
/// The set the plate drive runs in — the overhead-name driver orders after it (the
|
||||
@@ -1090,14 +813,24 @@ impl Plugin for VPlatesPlugin {
|
||||
app.init_resource::<VPlateMode>()
|
||||
.init_resource::<VPlates>()
|
||||
.init_resource::<PlateRects>()
|
||||
.add_systems(Startup, load_plate_art.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_vplates,
|
||||
// After the targeting chain (selection/hover verdicts), inside the UI-quad
|
||||
// append window (the mesh rebuild waits on it).
|
||||
drive_vplates.after(TargetUpdate).in_set(UiQuadAppend),
|
||||
// After the targeting chain (selection/hover verdicts) — which is
|
||||
// `.after(WorldStage::Input)`, so the camera this projects through is THIS
|
||||
// frame's, the freshest data a plate can be built from.
|
||||
//
|
||||
// **The paint is therefore one frame behind, and that is a known cost of
|
||||
// decision 2148.** The UI's tick→resolve→extract (`UiInput`) runs
|
||||
// `.before(WorldStage::Input)`, so the anchors written here are drawn by the
|
||||
// NEXT frame's extract. Running earlier does not help — the camera would then
|
||||
// be last frame's instead, and the lag would be identical with staler
|
||||
// targeting. Removing it means moving the UI's resolve/extract after the
|
||||
// camera update, which is a seam change with its own blast radius (the hover
|
||||
// hit-test reads those rects before `WorldStage::Input`) and is recorded as
|
||||
// 2148's follow-up rather than smuggled in here.
|
||||
drive_vplates.after(TargetUpdate),
|
||||
)
|
||||
.chain()
|
||||
.in_set(VPlateSet),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/// **premultiplied** sRGB so alpha edges don't dark-fringe. On magnification the per-axis boundary
|
||||
/// ramp is compressed to ~1 output pixel (crisp); on minification it falls back to plain bilinear
|
||||
/// (`sharpen = max(scale, 1)`), so a plate smaller than native still resolves cleanly.
|
||||
pub(super) fn resample_sharp(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
|
||||
pub(crate) fn resample_sharp(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
|
||||
let fetch = |x: i32, y: i32| -> [f32; 4] {
|
||||
let x = x.clamp(0, sw as i32 - 1) as u32;
|
||||
let y = y.clamp(0, sh as i32 - 1) as u32;
|
||||
|
||||
@@ -64,6 +64,14 @@ pub struct WorldAssets {
|
||||
/// each address-mode pair needs its own GPU image (the sampler is baked into the `Image`).
|
||||
/// See [`Self::sprite_texture_wrapped`].
|
||||
tiled_sprites: HashMap<(String, bool, bool), Option<Handle<Image>>>,
|
||||
/// Sprites RESAMPLED to an exact physical size, by `(path, w, h)` — the nameplate border's
|
||||
/// 0188 sharpen and nothing else so far. Keyed by size because that is the whole point: the
|
||||
/// art is re-rasterised whenever the plate's pixel size moves, and reused on every frame in
|
||||
/// between (a resize is rare; a plate is drawn 60 times a second).
|
||||
resampled_sprites: HashMap<(String, u32, u32), Option<Handle<Image>>>,
|
||||
/// The decoded source pixels behind [`Self::resampled_sprites`], so a resize re-samples
|
||||
/// instead of re-decoding the BLP.
|
||||
resample_sources: HashMap<String, Option<(u32, u32, Vec<u8>)>>,
|
||||
/// Decoded **portrait** sprites — [`Self::sprite_texture`]'s clamp/sRGB sprite with a circular
|
||||
/// alpha mask baked in ([`portrait_image`]). Its own cache, like `tiled_sprites`: the mask bakes
|
||||
/// into the GPU image, so the same BLP wanted as a plain icon and as a portrait needs two images.
|
||||
@@ -385,6 +393,8 @@ impl WorldAssets {
|
||||
chain: Arc::new(Mutex::new(chain)),
|
||||
textures: SpatialCache::default(),
|
||||
sprites: HashMap::new(),
|
||||
resampled_sprites: HashMap::new(),
|
||||
resample_sources: HashMap::new(),
|
||||
tiled_sprites: HashMap::new(),
|
||||
portraits: HashMap::new(),
|
||||
masks: HashMap::new(),
|
||||
@@ -466,6 +476,38 @@ impl WorldAssets {
|
||||
decode_sprite(&self.chain, self.loose_root.as_deref(), path)
|
||||
}
|
||||
|
||||
/// A UI sprite **resampled to an exact pixel size** by the caller's own kernel, cached by that
|
||||
/// size — the nameplate border (decision 0188).
|
||||
///
|
||||
/// The plate's frame art is a 128 × 32 BLP drawn at whatever size the plate is, which past the
|
||||
/// 1024×768 knee (and always on a retina framebuffer) is a magnification: the GPU's bilinear
|
||||
/// filter smears the 1 px gold bevel across several soft output pixels, which is the director's
|
||||
/// "blurry border". Resampling the SAME pixels to the target size with a sharp kernel keeps the
|
||||
/// art and loses the smear. This lives here rather than in the plate driver because since
|
||||
/// decision 2148 the plate is a widget like any other and its border is an ordinary texture
|
||||
/// region — the substitution has to happen where a texture path becomes an image.
|
||||
pub fn resampled_sprite(
|
||||
&mut self,
|
||||
path: &str,
|
||||
(w, h): (u32, u32),
|
||||
images: &mut Assets<Image>,
|
||||
resample: impl FnOnce(&[u8], u32, u32, u32, u32) -> Vec<u8>,
|
||||
) -> Option<Handle<Image>> {
|
||||
let key = (path.to_string(), w, h);
|
||||
if let Some(cached) = self.resampled_sprites.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
if !self.resample_sources.contains_key(path) {
|
||||
let decoded = decode_sprite(&self.chain, self.loose_root.as_deref(), path);
|
||||
self.resample_sources.insert(path.to_string(), decoded);
|
||||
}
|
||||
let made = self.resample_sources[path]
|
||||
.as_ref()
|
||||
.map(|(sw, sh, src)| images.add(sprite_image(w, h, resample(src, *sw, *sh, w, h))));
|
||||
self.resampled_sprites.insert(key, made.clone());
|
||||
made
|
||||
}
|
||||
|
||||
/// A UI sprite decoded with **repeat** (wrap) addressing on both axes — the frame `Backdrop`
|
||||
/// tiled pieces (`backdrop-mechanism.md`): a border edge strip samples UVs `[0..N]` and a tiled
|
||||
/// bg `[0..w/period]`, so the texture must wrap, not clamp. [`Self::sprite_texture_wrapped`]
|
||||
|
||||
@@ -110,6 +110,8 @@ mod measure;
|
||||
mod merchant;
|
||||
mod messageframe;
|
||||
mod minimap;
|
||||
pub mod nameplate;
|
||||
pub use nameplate::{PlateGeometry, PlateState};
|
||||
mod model;
|
||||
mod modelframe;
|
||||
mod net_stats;
|
||||
|
||||
@@ -1619,6 +1619,11 @@ pub(crate) struct Model {
|
||||
/// The world-map seam ([`worldmap`](super::worldmap)): the pushed catalog/feed + the
|
||||
/// engine-owned selection.
|
||||
pub(crate) worldmap: super::worldmap::WorldMapState,
|
||||
/// The V-key nameplate pool — engine-owned `Button` widgets under the `WorldFrame`, grown on
|
||||
/// demand and never shrunk, in creation order (decision 2148). Lives here rather than on
|
||||
/// [`super::UiScript`] because it IS model state: the plates are arena frames, and an addon
|
||||
/// walking `WorldFrame:GetChildren()` reaches them like any other.
|
||||
pub(crate) nameplates: super::nameplate::NamePlates,
|
||||
/// The always-up world-state readout's rows ([`worldstate`](super::worldstate)), already
|
||||
/// gated and resolved app-side.
|
||||
pub(crate) worldstate: super::worldstate::WorldStateUiState,
|
||||
@@ -2139,6 +2144,7 @@ impl Model {
|
||||
quest_log_watched: Vec::new(),
|
||||
server_unix_time: None,
|
||||
worldmap: super::worldmap::WorldMapState::default(),
|
||||
nameplates: super::nameplate::NamePlates::default(),
|
||||
worldstate: super::worldstate::WorldStateUiState::default(),
|
||||
pending_events: Vec::new(),
|
||||
cursor_pos: (0.0, 0.0),
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! **The nameplate widgets** — `CGNamePlateFrame` as the frame-system object it actually is
|
||||
//! (decision 2148; wow-re `system/ui/scratch/nameplate-lua-surface.md`, §5 eight-worker round +
|
||||
//! orchestrator byte arbitration 2026-09-09, benilla dispatch).
|
||||
//!
|
||||
//! benilla drew V-plates for a year as quads pushed straight into the UI pass, which made them
|
||||
//! invisible to Lua: a live run's `WorldFrame:GetChildren()` returned the two named FrameXML
|
||||
//! frames and nothing else, and every 1.12 nameplate addon — all of which find plates by walking
|
||||
//! exactly that list — found nothing to work with. The reference does the opposite, and this
|
||||
//! module is that difference closed.
|
||||
//!
|
||||
//! ## What the reference builds, byte for byte
|
||||
//!
|
||||
//! The plate is a `CSimpleButton` subclass (ctor `0x7cb250`, `…\Ui\NamePlateFrame.cpp`, vtable
|
||||
//! `0x81de50`, size `0x518`) whose **base constructor parents it to the `CGWorldFrame` singleton**
|
||||
//! `ds:0xb4b2bc` (`0x7786a3` → `0x769318` → `SetParent 0x76ab10`, tail-appending at `0x76abdb`) —
|
||||
//! so it is a real member of `[worldframe+0x300]`, returned by `GetChildren` and counted by
|
||||
//! `GetNumChildren`, inheriting strata **0 (WORLD)** and taking level **1**.
|
||||
//!
|
||||
//! - **Identity:** `GetObjectType()` = `"Button"`, `IsObjectType` accepts Button/Frame/Region, and
|
||||
//! `GetName()` is **nil** — `[this+0x98]` is zeroed at `0x76c50b` and only XML `name=` ever
|
||||
//! writes it. Anonymity is load-bearing: `_Nameplates` and pfUI both reject a *named*
|
||||
//! `WorldFrame` child.
|
||||
//! - **Six regions, in creation order**: Border `0x7cb2c2`, Glow `0x7cb361`, Name `0x7cb3d7`,
|
||||
//! Level `0x7cb479`, Skull `0x7cb512`, RaidIcon `0x7cb5b4`.
|
||||
//! - **One child**, the health bar (`GetObjectType()` = `"StatusBar"`), whose own single region is
|
||||
//! the BarFill texture `GetStatusBarTexture()` returns. **No cast bar exists in 1.12** — a second
|
||||
//! child would be blanked by pfUI, which destructures `healthbar, castbar`.
|
||||
//! - **Real anchors.** The ctor writes them through `0x767c70` — the *same* primitive and the same
|
||||
//! `[layoutObj + 4 + 4·point]` storage the Lua `SetPoint` binding ends in — so `GetPoint`,
|
||||
//! `GetLeft` and `GetWidth` answer truthfully. Name = `("BOTTOM", plate, "CENTER", 0, 0)`
|
||||
//! (`0x7cb456`), Level = `("CENTER", plate, "BOTTOMRIGHT", …)` (`0x7cb7f5`); `_Nameplates`
|
||||
//! *validates* both pairs and rejects a plate that fails them.
|
||||
//! - **Levels, and this is the surprise:** the bar is put **below** the plate, not above it —
|
||||
//! `0x7cb32e mov edx,[plate+0xc4]` (=1) · `0x7cb33c dec edx` · `0x7cb33e call 0x76a4f0(bar,0,0)`,
|
||||
//! an absolute level that overrides what `SetParent` had just given it. Plate level 1, **bar
|
||||
//! level 0**. Since the draw walk's layer loop is *outer* and its frame walk *inner* (`0x765920`:
|
||||
//! layer counter `0x76593c`, frame walk `0x7659d0`–`0x7659f8`, `cmp eax,5` at `0x765a63`), the
|
||||
//! whole bar frame drains before any plate layer — which is why the border's bevels cap the
|
||||
//! fill's ends. The director pinned that look off a reference crop while `nameplate-vkey.md` §7
|
||||
//! recorded the opposite order; the §5 round settled it in the director's favour and corrected
|
||||
//! the note.
|
||||
//! - **Layers** (`GetDrawLayer 0x79a6c0`, table `0x811a80`): Border ARTWORK, Glow **HIGHLIGHT**,
|
||||
//! Name/Level/Skull OVERLAY (the ctor re-layers Name and Level off ARTWORK at `0x7cb438`/
|
||||
//! `0x7cb4ea`), RaidIcon ARTWORK, BarFill ARTWORK on the bar. **No sub-level exists in 5875**;
|
||||
//! within a layer it is creation order.
|
||||
//! - **Scripts:** the engine installs none, and every C++ override tails into the base
|
||||
//! unconditionally, so an addon's `SetScript` on OnShow/OnHide/OnUpdate/OnEnter/OnLeave/
|
||||
//! OnMouseDown/OnMouseUp/OnClick fires. `RegisterForClicks(0x500)` = Left/Right on mouse-**up**.
|
||||
//! - **Pooling:** destroy (`0x608a10`) never unparents — it hides and pushes onto a free list, and
|
||||
//! reuse re-runs neither the ctor nor `SetParent`. So **the child-list index is stable for the
|
||||
//! session**, and a retired plate is still returned *and counted* (the list walk has no
|
||||
//! visibility filter). Every corpus addon's `registry[plate]` identity map rests on that.
|
||||
//!
|
||||
//! ## The consumer contract this has to keep
|
||||
//!
|
||||
//! Four independent 1.12 addons (`_Nameplates`, `CustomNameplates`, pfUI's vanilla branch,
|
||||
//! ShaguTweaks' vanilla branch — all `## Interface: 11200`) destructure the two tuples
|
||||
//! *positionally* and identify a plate by `regions[1]:GetTexture()` being exactly
|
||||
//! `Interface\Tooltips\Nameplate-Border`. The byte-derived order and the corpus's agree element for
|
||||
//! element — an independent empirical control on a byte derivation. So the composition is not ours
|
||||
//! to choose; it is an ABI, and the tests transcribe each addon's own walk.
|
||||
//!
|
||||
//! **An addon takes the plate over**, which is the half a painter never had to respect: pfUI blanks
|
||||
//! all six regions and paints its own, sets `SetAlpha(.95)` on non-targets itself, and ShaguTweaks
|
||||
//! reparents the raid icon. So [`Plate::create`] writes the static properties (paths, fonts,
|
||||
//! layers) exactly **once**, [`Plate::lay_out`] rewrites the window-derived anchors only when the
|
||||
//! window moves, and [`Plate::drive`] writes only what actually changed this frame. The one
|
||||
//! deliberate exception is the plate's own anchor, which the reference itself re-issues every dirty
|
||||
//! frame from the seat `0x509ec0` (`ClearAllPoints` + `SetPoint`), wiping an addon anchor *on the
|
||||
//! plate* there too — which is why every corpus addon anchors its overlay **to** the plate and
|
||||
//! re-anchors the **bar**, never the plate.
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::order::DrawLayer;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
|
||||
|
||||
use super::model::Model;
|
||||
use super::UiScript;
|
||||
use super::{FontShadow, JustifyV, TexCoords};
|
||||
|
||||
/// Region 1's art — and the identification test every corpus addon runs, which is why it is a
|
||||
/// constant here rather than a caller's argument.
|
||||
pub const BORDER_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Border";
|
||||
/// Region 2's. `glow:IsShown()` is how pfUI reads *mouseover* (`nameplates.lua:601`).
|
||||
pub const GLOW_TEXTURE: &str = "Interface\\Tooltips\\Nameplate-Glow";
|
||||
/// Region 5's — the boss / out-of-range skull (`levelicon` to pfUI, `Boss` to CustomNameplates).
|
||||
pub const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Skull";
|
||||
/// Region 6's 4-column atlas.
|
||||
pub const RAID_ICON_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||||
/// The health bar's fill — what `healthbar:GetStatusBarTexture()` hands back.
|
||||
pub const BAR_FILL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
|
||||
/// `NAMEPLATE_FONT` — Friz Quadrata, the plate's own face.
|
||||
pub const PLATE_FONT: &str = "Fonts\\FRIZQT__.TTF";
|
||||
|
||||
/// The plate's frame level, and the bar's — `plate.level − 1`, absolute and clamped ≥ 0
|
||||
/// (`0x7cb32e`/`0x7cb33c`/`0x7cb33e` → `0x76a4f0`). The pair is what puts the border over the fill.
|
||||
const PLATE_LEVEL: u16 = 1;
|
||||
const BAR_LEVEL: u16 = 0;
|
||||
|
||||
/// Everything about a plate's geometry that follows from the *window* rather than from the unit,
|
||||
/// in FrameXML units (the driver has already divided out the seam scale).
|
||||
///
|
||||
/// Applied to every pooled plate at once, and only when it changes — these are exactly the writes
|
||||
/// that must not happen per frame, because an addon that re-anchors the bar would otherwise have
|
||||
/// its anchor stamped over at 60 Hz.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlateGeometry {
|
||||
/// The plate frame's own size.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// The health bar, from the plate's BOTTOMLEFT.
|
||||
pub bar_off_x: f32,
|
||||
pub bar_off_y: f32,
|
||||
pub bar_width: f32,
|
||||
pub bar_height: f32,
|
||||
/// The level text / skull seat, from the plate's BOTTOMRIGHT (x is subtracted, y added).
|
||||
pub level_off_x: f32,
|
||||
pub level_off_y: f32,
|
||||
/// The skull's square side.
|
||||
pub skull_size: f32,
|
||||
/// The raid icon's square side; it hangs off the plate's LEFT edge, vertically centred.
|
||||
pub raid_size: f32,
|
||||
/// Font heights for the name and the level.
|
||||
pub name_height: f32,
|
||||
pub level_height: f32,
|
||||
}
|
||||
|
||||
/// One plate's per-frame state, as the app hands it over: everything that can move while a plate is
|
||||
/// alive. Anything that cannot is written once, at creation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PlateState {
|
||||
/// The unit this plate belongs to — its GUID, and **the plate's identity**.
|
||||
///
|
||||
/// The reference binds a plate to its unit for as long as the plate lives (`[unit+0xe60]`
|
||||
/// holds the pointer; the free list hands out a plate at create and takes it back at destroy),
|
||||
/// and every corpus addon depends on that: pfUI caches the name, level, colour and its own
|
||||
/// overlay ON the frame (`registry[plate]`, `nameplate.original.*`) and only refreshes what
|
||||
/// its `OnDataChanged` says moved. Handing widget *i* to whichever unit happens to sort i-th
|
||||
/// this frame would make every plate's cached state wrong on the frame two units swap places —
|
||||
/// which is why the pool is keyed by this rather than indexed by the driver's sort order.
|
||||
pub key: u64,
|
||||
/// Where the plate's **TOP-CENTRE** lands, in FrameXML units from the screen's bottom-left —
|
||||
/// the reference's own seat (`nameplate-vkey.md` §8.5: the plate hangs below the point).
|
||||
pub top_centre: (f32, f32),
|
||||
/// `UNIT_FIELD_HEALTH` and `UNIT_FIELD_MAXHEALTH`, **raw**: `healthbar:GetValue()` is
|
||||
/// `[bar+0x320]` (`0x78f5d0`), not a fraction, and `GetMinMaxValues()` answers `(0, max)`.
|
||||
/// pfUI and CustomNameplates both divide, so a fraction here would read as 1 hp out of 1.
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
/// The bar's reaction colour — the four confirmed dwords. Addons reverse-engineer the unit's
|
||||
/// faction from `GetStatusBarColor`, so these have to be exact.
|
||||
pub bar_colour: [f32; 3],
|
||||
/// The unit's name.
|
||||
pub name: String,
|
||||
/// The level text, or `None` to show the skull in the level's seat instead.
|
||||
pub level: Option<u32>,
|
||||
/// The level text's con colour.
|
||||
pub level_colour: [f32; 3],
|
||||
/// The raid-target mark, 0-based (`col = idx & 3`, `row = idx >> 2`), or `None`.
|
||||
pub raid_icon: Option<u8>,
|
||||
/// The plate's own alpha: **exactly 1.0 for the target**, dimmed otherwise. `GetAlpha() == 1`
|
||||
/// is how pfUI and CustomNameplates detect the target plate — a contract, not a look.
|
||||
pub alpha: f32,
|
||||
/// Mouseover ∪ target: the bar's brighten.
|
||||
pub lit: bool,
|
||||
/// The plate rect is hovered right now — the name goes yellow (`0x7cb850` OnEnter, name-only)
|
||||
/// and the glow region is shown, which is the mouseover signal addons read.
|
||||
pub hovered: bool,
|
||||
}
|
||||
|
||||
/// One pooled plate's widgets, in the reference's creation order.
|
||||
#[derive(Debug)]
|
||||
struct Plate {
|
||||
frame: FrameHandle,
|
||||
border: RegionHandle,
|
||||
glow: RegionHandle,
|
||||
name: RegionHandle,
|
||||
level: RegionHandle,
|
||||
skull: RegionHandle,
|
||||
raid: RegionHandle,
|
||||
bar: FrameHandle,
|
||||
fill: RegionHandle,
|
||||
/// Last frame's state, so [`Plate::drive`] writes only what moved. `None` = retired.
|
||||
last: Option<PlateState>,
|
||||
}
|
||||
|
||||
/// The plate pool — grown on demand, **never shrunk**, in creation order.
|
||||
///
|
||||
/// The reference's pool is unbounded and its free list FIFO; what matters to an addon is only that
|
||||
/// an index, once handed out, keeps meaning the same Lua object, and that a retired plate stays in
|
||||
/// the child list (hidden). pfUI and ShaguTweaks scan `initialized + 1 .. GetNumChildren()` and
|
||||
/// never reset `initialized`, so a client that reordered or compacted this list would make them
|
||||
/// miss every plate created afterwards — permanently, and silently.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct NamePlates {
|
||||
plates: Vec<Plate>,
|
||||
/// Which pool slot each live unit holds, by [`PlateState::key`] — the reference's
|
||||
/// `[unit+0xe60]`, from this side. A slot leaves this map only when its unit's plate is
|
||||
/// retired, and the slot itself never moves.
|
||||
assigned: std::collections::HashMap<u64, usize>,
|
||||
/// The geometry every live plate is currently laid out under.
|
||||
geometry: Option<PlateGeometry>,
|
||||
}
|
||||
|
||||
/// What a sync produced that has to be fired **outside** the model borrow: the frames whose
|
||||
/// effective visibility flipped (their `OnShow`/`OnHide`), and the bars whose value moved (their
|
||||
/// `OnValueChanged`, which pfUI hooks — `nameplates.lua:393` — and reads `this:GetParent()` in).
|
||||
#[derive(Default)]
|
||||
struct SyncEffects {
|
||||
visibility: Vec<FrameHandle>,
|
||||
values: Vec<(FrameHandle, f32)>,
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// Drive the plate pool from the app's per-frame verdict: `states[i]` is the i-th **shown**
|
||||
/// plate, and every pooled plate past `states.len()` is retired (hidden, never destroyed).
|
||||
///
|
||||
/// `geometry` is the window-derived half; passing an unchanged value costs nothing.
|
||||
pub fn sync_nameplates(&mut self, geometry: PlateGeometry, states: &[PlateState]) {
|
||||
let lua = self.lua();
|
||||
let effects = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// No WorldFrame, no plates: a VM with no interface loaded (a bare test app, the addon
|
||||
// harness) has nowhere to parent them, and inventing a root would put plates somewhere
|
||||
// an addon's walk could never find them anyway.
|
||||
let Some(world) = model.arena.lookup("WorldFrame") else {
|
||||
return;
|
||||
};
|
||||
sync(&mut model, world, geometry, states)
|
||||
};
|
||||
super::event::fire_visibility_changes(lua, effects.visibility);
|
||||
for (bar, value) in effects.values {
|
||||
super::statusbar::fire_engine_value_changed(lua, bar, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many plate widgets the pool holds — the plate driver's census, and the tests'.
|
||||
pub fn nameplate_pool_len(&self) -> usize {
|
||||
let lua = self.lua();
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.nameplates.plates.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole per-frame drive: grow, re-lay-out if the window moved, write what changed, retire the
|
||||
/// tail.
|
||||
fn sync(
|
||||
model: &mut Model,
|
||||
world: FrameHandle,
|
||||
geometry: PlateGeometry,
|
||||
states: &[PlateState],
|
||||
) -> SyncEffects {
|
||||
let mut effects = SyncEffects::default();
|
||||
let relayout = model.nameplates.geometry != Some(geometry);
|
||||
model.nameplates.geometry = Some(geometry);
|
||||
|
||||
// Retire first, so a unit that left this frame frees its slot for one that arrived — the
|
||||
// reference's destroy-then-create through the same free list, in the same tick.
|
||||
let gone: Vec<u64> = model
|
||||
.nameplates
|
||||
.assigned
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|k| !states.iter().any(|s| s.key == *k))
|
||||
.collect();
|
||||
for key in gone {
|
||||
if let Some(i) = model.nameplates.assigned.remove(&key) {
|
||||
Plate::retire(model, i, &mut effects);
|
||||
}
|
||||
}
|
||||
|
||||
for state in states {
|
||||
let slot = match model.nameplates.assigned.get(&state.key) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
// The lowest free slot, else a new one at the tail. Lowest-free rather than the
|
||||
// reference's FIFO free list: both keep every existing index meaning what it did,
|
||||
// which is the only property an addon can observe.
|
||||
let free = (0..model.nameplates.plates.len())
|
||||
.find(|i| model.nameplates.plates[*i].last.is_none());
|
||||
let i = free.unwrap_or_else(|| {
|
||||
let plate = Plate::create(model, world, &mut effects);
|
||||
model.nameplates.plates.push(plate);
|
||||
model.nameplates.plates.len() - 1
|
||||
});
|
||||
model.nameplates.assigned.insert(state.key, i);
|
||||
i
|
||||
}
|
||||
};
|
||||
if relayout {
|
||||
Plate::lay_out(model, slot, geometry);
|
||||
}
|
||||
Plate::drive(model, slot, world, state, &mut effects);
|
||||
}
|
||||
|
||||
// A geometry change reaches the retired plates too: they are still in the child list, an addon
|
||||
// can still read them, and they must not come back next frame laid out for the old window.
|
||||
if relayout {
|
||||
for i in 0..model.nameplates.plates.len() {
|
||||
if model.nameplates.plates[i].last.is_none() {
|
||||
Plate::lay_out(model, i, geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
impl Plate {
|
||||
/// Build one plate: the Button under the WorldFrame, its six regions in the reference's
|
||||
/// creation order, and the health-bar child with its fill.
|
||||
///
|
||||
/// Everything here is written **once**. An addon that blanks a region (pfUI's `DisableObject`
|
||||
/// does `SetTexture("")`) must find it still blank on the next frame.
|
||||
fn create(model: &mut Model, world: FrameHandle, effects: &mut SyncEffects) -> Plate {
|
||||
// Anonymous, and that is a requirement rather than an omission (module doc).
|
||||
let frame = model.arena.create(FrameKind::Button, None, Some(world));
|
||||
let strata = model
|
||||
.arena
|
||||
.frame(world)
|
||||
.map(|f| f.strata)
|
||||
.unwrap_or_default();
|
||||
model.arena.set_frame_strata(frame, strata);
|
||||
model.arena.set_frame_level(frame, PLATE_LEVEL, true);
|
||||
// **The mouse bit stays OFF, and that is a deliberate gap, not an omission** (2148 §8).
|
||||
// The reference's plate is mouse-enabled — hovering it publishes the mouseover
|
||||
// (`0x7cb850` → `0x492890`) and a click selects through the button's own click slot. On
|
||||
// this engine, a mouse-enabled frame under the cursor is what makes `PointerOverUi` true,
|
||||
// and `target::hover::update_hover` returns early on exactly that: setting the bit without
|
||||
// an engine-side hover/click seam takes the hover and gives nothing back — no model
|
||||
// brighten, no lit bar, and no selection, because the click stops at the UI too. The seam
|
||||
// comes first; then the bit, in the same change.
|
||||
// `a_plate_does_not_yet_claim_the_ui_pointer` is the tripwire.
|
||||
//
|
||||
// It has to be turned OFF explicitly: a `Button` is born mouse-enabled here, faithfully —
|
||||
// `CSimpleButton`'s ctor writes `[+0xcc] = 0x4` (`0x7786a3`), which is why the plate needs
|
||||
// no `EnableMouse` of its own in the reference.
|
||||
model.arena.set_mouse_enabled(frame, false);
|
||||
|
||||
let border = texture(model, frame, DrawLayer::Artwork, BORDER_TEXTURE);
|
||||
let glow = texture(model, frame, DrawLayer::Highlight, GLOW_TEXTURE);
|
||||
let name = font_string(model, frame, JustifyV::Bottom);
|
||||
let level = font_string(model, frame, JustifyV::Middle);
|
||||
let skull = texture(model, frame, DrawLayer::Overlay, SKULL_TEXTURE);
|
||||
let raid = texture(model, frame, DrawLayer::Artwork, RAID_ICON_TEXTURE);
|
||||
// ↑ THE ORDER. Six regions, and this sequence is the ABI (module doc).
|
||||
|
||||
// The one child, below the plate's own level so the border draws over the fill.
|
||||
let bar = model.arena.create(FrameKind::StatusBar, None, Some(frame));
|
||||
model.arena.set_frame_strata(bar, strata);
|
||||
model.arena.set_frame_level(bar, BAR_LEVEL, true);
|
||||
let fill = model
|
||||
.arena
|
||||
.create_region(bar, RegionKind::Texture, DrawLayer::Artwork, 0)
|
||||
.expect("live bar");
|
||||
model.region_data.entry(fill).or_default().texture = Some(BAR_FILL_TEXTURE.to_string());
|
||||
if let Some(KindState::StatusBar(sb)) =
|
||||
model.arena.frame_mut(bar).map(|f| &mut f.kind_state)
|
||||
{
|
||||
sb.bar = Some(fill);
|
||||
sb.min = 0.0;
|
||||
sb.max = 1.0;
|
||||
sb.value = 0.0;
|
||||
}
|
||||
|
||||
// The skull and the raid icon are the two regions that only sometimes show; the glow is
|
||||
// shown on hover alone. The rest are always up while the plate is.
|
||||
for rh in [glow, skull, raid] {
|
||||
model.region_data.entry(rh).or_default().hidden = true;
|
||||
}
|
||||
|
||||
// Born retired: a fresh plate belongs to no unit yet, and `IsShown()` is what an addon
|
||||
// reads to tell a live plate from a pooled one.
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, false));
|
||||
|
||||
Plate {
|
||||
frame,
|
||||
border,
|
||||
glow,
|
||||
name,
|
||||
level,
|
||||
skull,
|
||||
raid,
|
||||
bar,
|
||||
fill,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// (Re)lay the static anchors — everything that follows from the window's size rather than the
|
||||
/// unit. Called on the first drive and on a geometry change, never per frame.
|
||||
fn lay_out(model: &mut Model, i: usize, g: PlateGeometry) {
|
||||
let p = &model.nameplates.plates[i];
|
||||
let (frame, bar) = (p.frame, p.bar);
|
||||
let (border, glow, name, level, skull, raid) =
|
||||
(p.border, p.glow, p.name, p.level, p.skull, p.raid);
|
||||
let plate_id = model.frame_id(frame);
|
||||
|
||||
// The plate's own size; its position is per-frame and lives in `drive`.
|
||||
let input = model.layout_inputs.entry(frame).or_default();
|
||||
input.width = g.width;
|
||||
input.height = g.height;
|
||||
|
||||
// The border fills the plate, and so does the glow — a rim over the whole frame.
|
||||
for rh in [border, glow] {
|
||||
model.region_data.entry(rh).or_default().anchors = vec![
|
||||
Anchor::new(Point::TopLeft, plate_id, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, plate_id, Point::BottomRight, 0.0, 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
// The health bar: BOTTOMLEFT + (off_x, off_y), explicit size.
|
||||
let bar_input = model.layout_inputs.entry(bar).or_default();
|
||||
bar_input.anchors = vec![Anchor::new(
|
||||
Point::BottomLeft,
|
||||
plate_id,
|
||||
Point::BottomLeft,
|
||||
g.bar_off_x,
|
||||
g.bar_off_y,
|
||||
)];
|
||||
bar_input.width = g.bar_width;
|
||||
bar_input.height = g.bar_height;
|
||||
|
||||
// The name — ("BOTTOM", plate, "CENTER", 0, 0), the pair `_Nameplates` validates.
|
||||
let name_data = model.region_data.entry(name).or_default();
|
||||
name_data.anchors = vec![Anchor::new(
|
||||
Point::Bottom,
|
||||
plate_id,
|
||||
Point::Center,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
name_data.font_height = Some(g.name_height);
|
||||
|
||||
// The level — ("CENTER", plate, "BOTTOMRIGHT", −x, +y), the other validated pair.
|
||||
let level_data = model.region_data.entry(level).or_default();
|
||||
level_data.anchors = vec![Anchor::new(
|
||||
Point::Center,
|
||||
plate_id,
|
||||
Point::BottomRight,
|
||||
-g.level_off_x,
|
||||
g.level_off_y,
|
||||
)];
|
||||
level_data.font_height = Some(g.level_height);
|
||||
level_data.font_explicit.height = true;
|
||||
model.touch_measure(level);
|
||||
|
||||
// The skull replaces the level number, in the level's own seat.
|
||||
let skull_data = model.region_data.entry(skull).or_default();
|
||||
skull_data.anchors = vec![Anchor::new(
|
||||
Point::Center,
|
||||
plate_id,
|
||||
Point::BottomRight,
|
||||
-g.level_off_x,
|
||||
g.level_off_y,
|
||||
)];
|
||||
skull_data.size = Some((g.skull_size, g.skull_size));
|
||||
|
||||
// The raid icon hangs off the LEFT edge, vertically centred.
|
||||
let raid_data = model.region_data.entry(raid).or_default();
|
||||
raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)];
|
||||
raid_data.size = Some((g.raid_size, g.raid_size));
|
||||
|
||||
// Anchor TARGETS moved (from nothing to the plate) on the first lay-out, so this is the
|
||||
// conservative touch — the only one in this module, and it runs once per geometry change.
|
||||
model.touch_layout();
|
||||
}
|
||||
|
||||
/// The per-frame write: position, health, texts, colours, shown flags, alpha — **and nothing
|
||||
/// else**, each only when it moved.
|
||||
fn drive(
|
||||
model: &mut Model,
|
||||
i: usize,
|
||||
world: FrameHandle,
|
||||
state: &PlateState,
|
||||
effects: &mut SyncEffects,
|
||||
) {
|
||||
let p = &model.nameplates.plates[i];
|
||||
let (frame, bar) = (p.frame, p.bar);
|
||||
let (glow, name, level, skull, raid, fill) =
|
||||
(p.glow, p.name, p.level, p.skull, p.raid, p.fill);
|
||||
let last = p.last.clone();
|
||||
let world_id = model.frame_id(world);
|
||||
|
||||
// The seat, re-issued whenever it moves, exactly like the reference's own (`0x509ec0`:
|
||||
// ClearAllPoints then SetPoint) — which is why an addon anchor on the plate does not
|
||||
// survive there either. A moved OFFSET keeps the graph's edges, so the precise touch
|
||||
// applies; the first seat adds an edge and takes the retarget.
|
||||
if last
|
||||
.as_ref()
|
||||
.is_none_or(|l| l.top_centre != state.top_centre)
|
||||
{
|
||||
let fresh = model
|
||||
.layout_inputs
|
||||
.get(&frame)
|
||||
.is_none_or(|i| i.anchors.is_empty());
|
||||
let input = model.layout_inputs.entry(frame).or_default();
|
||||
input.anchors = vec![Anchor::new(
|
||||
Point::Top,
|
||||
world_id,
|
||||
Point::BottomLeft,
|
||||
state.top_centre.0,
|
||||
state.top_centre.1,
|
||||
)];
|
||||
if fresh {
|
||||
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
|
||||
} else {
|
||||
model.touch_layout_frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
if last.is_none() {
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, true));
|
||||
}
|
||||
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
|
||||
model.arena.set_alpha(frame, state.alpha);
|
||||
}
|
||||
|
||||
// The bar reports RAW health (module doc). Its fill carries the reaction colour, lifted by
|
||||
// a uniform brighten while the unit is the mouseover or the target — 0184's form of the
|
||||
// highlight, in place of the additive `Nameplate-Glow` rim.
|
||||
if last
|
||||
.as_ref()
|
||||
.is_none_or(|l| l.health != state.health || l.max_health != state.max_health)
|
||||
{
|
||||
if let Some(KindState::StatusBar(sb)) =
|
||||
model.arena.frame_mut(bar).map(|f| &mut f.kind_state)
|
||||
{
|
||||
sb.min = 0.0;
|
||||
sb.max = state.max_health;
|
||||
sb.value = state.health.clamp(0.0, state.max_health);
|
||||
}
|
||||
effects.values.push((bar, state.health));
|
||||
}
|
||||
if last
|
||||
.as_ref()
|
||||
.is_none_or(|l| l.bar_colour != state.bar_colour || l.lit != state.lit)
|
||||
{
|
||||
let boost = if state.lit { LIT_BOOST } else { 1.0 };
|
||||
let c = state.bar_colour;
|
||||
model.region_data.entry(fill).or_default().vertex_color =
|
||||
Some([c[0] * boost, c[1] * boost, c[2] * boost, 1.0]);
|
||||
}
|
||||
|
||||
// The name: white, yellow while the plate itself is hovered.
|
||||
if last.as_ref().is_none_or(|l| l.name != state.name) {
|
||||
model.region_data.entry(name).or_default().text = Some(state.name.clone());
|
||||
model.touch_measure(name);
|
||||
model.touch_layout_region(name);
|
||||
}
|
||||
if last.as_ref().is_none_or(|l| l.hovered != state.hovered) {
|
||||
let c = if state.hovered {
|
||||
[1.0, 1.0, 0.0, 1.0]
|
||||
} else {
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
};
|
||||
model.region_data.entry(name).or_default().vertex_color = Some(c);
|
||||
// `glow:IsShown()` IS the mouseover signal (pfUI `nameplates.lua:601`). The region is
|
||||
// shown for real; that it paints nothing is 0184's deviation, not a missing region.
|
||||
model.region_data.entry(glow).or_default().hidden = !state.hovered;
|
||||
}
|
||||
|
||||
// Level text and skull are mutually exclusive, in one seat.
|
||||
if last
|
||||
.as_ref()
|
||||
.is_none_or(|l| l.level != state.level || l.level_colour != state.level_colour)
|
||||
{
|
||||
match state.level {
|
||||
Some(n) => {
|
||||
let c = state.level_colour;
|
||||
let data = model.region_data.entry(level).or_default();
|
||||
data.text = Some(n.to_string());
|
||||
data.vertex_color = Some([c[0], c[1], c[2], 1.0]);
|
||||
data.hidden = false;
|
||||
model.region_data.entry(skull).or_default().hidden = true;
|
||||
model.touch_measure(level);
|
||||
model.touch_layout_region(level);
|
||||
}
|
||||
None => {
|
||||
model.region_data.entry(level).or_default().hidden = true;
|
||||
model.region_data.entry(skull).or_default().hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The raid mark: the 4-column atlas cell, shown only while the unit carries one.
|
||||
if last.as_ref().is_none_or(|l| l.raid_icon != state.raid_icon) {
|
||||
match state.raid_icon {
|
||||
Some(idx) => {
|
||||
let (u0, v0) = (f32::from(idx & 3) * 0.25, f32::from(idx >> 2) * 0.25);
|
||||
let data = model.region_data.entry(raid).or_default();
|
||||
data.tex_coords = Some(TexCoords::Rect([u0, u0 + 0.25, v0, v0 + 0.25]));
|
||||
data.hidden = false;
|
||||
}
|
||||
None => model.region_data.entry(raid).or_default().hidden = true,
|
||||
}
|
||||
}
|
||||
|
||||
model.nameplates.plates[i].last = Some(state.clone());
|
||||
}
|
||||
|
||||
/// Retire a plate: **hide it, never destroy it** (`0x608a10` — unlink from the active list,
|
||||
/// push onto the free list, `Hide`). It keeps its slot in `WorldFrame`'s child list, keeps its
|
||||
/// index, and comes back as the same Lua object, which is the contract every addon's
|
||||
/// `registry[plate]` identity map rests on.
|
||||
fn retire(model: &mut Model, i: usize, effects: &mut SyncEffects) {
|
||||
if model.nameplates.plates[i].last.is_none() {
|
||||
return;
|
||||
}
|
||||
let frame = model.nameplates.plates[i].frame;
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, false));
|
||||
model.nameplates.plates[i].last = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the plate's texture regions, with its path — created in the ABI's order by the caller.
|
||||
fn texture(model: &mut Model, frame: FrameHandle, layer: DrawLayer, path: &str) -> RegionHandle {
|
||||
let rh = model
|
||||
.arena
|
||||
.create_region(frame, RegionKind::Texture, layer, 0)
|
||||
.expect("live plate");
|
||||
model.region_data.entry(rh).or_default().texture = Some(path.to_string());
|
||||
rh
|
||||
}
|
||||
|
||||
/// One of the plate's two FontStrings. Both are OVERLAY — the ctor re-layers them off ARTWORK
|
||||
/// (`0x7cb438`, `0x7cb4ea`) — and both take `NAMEPLATE_FONT` with the plate's black 1 px drop
|
||||
/// shadow; the heights arrive with the geometry.
|
||||
///
|
||||
/// `justify_v` is the one benilla-side compensation here, and it is the old painter's, kept: the
|
||||
/// plate's name is seated by its **ink**, not by the line box. Our shaper's box is ascent-heavy
|
||||
/// for Friz, so a box-seated `BOTTOM` anchor drops the name ~8 px — half of it over the plate —
|
||||
/// which is the director's old "the level is not aligned" in its other half. `Bottom` puts the
|
||||
/// text where the reference's own metrics put it.
|
||||
fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> RegionHandle {
|
||||
let rh = model
|
||||
.arena
|
||||
.create_region(frame, RegionKind::FontString, DrawLayer::Overlay, 0)
|
||||
.expect("live plate");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.font_path = Some(PLATE_FONT.to_string());
|
||||
data.font_explicit.face = true;
|
||||
// The plate text's shadow (`nameplate-vkey.md` §7: black, ±0.001 gx, director-tuned to a
|
||||
// constant 1 px — the recorded value rounds to 2 px at a large window and reads chunky).
|
||||
data.font_shadow = Some(FontShadow {
|
||||
offset: [1.0, -1.0],
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
});
|
||||
data.justify.set_v(justify_v);
|
||||
rh
|
||||
}
|
||||
|
||||
/// The LIT (mouseover ∪ target) bar brighten — a uniform multiplicative lift of the fill tint,
|
||||
/// gradient untouched (0184: the recorded additive `Nameplate-Glow` rim read as hard edge lines on
|
||||
/// our linear-blending pipeline, and the director pinned the brighten instead).
|
||||
///
|
||||
/// Quad colours are client-space sRGB (`srgb_quad_color` linearizes them), so this multiplies
|
||||
/// ENCODED texels ~1:1 — the gamma-space modulate the client's FFP would do. **255/215 is the
|
||||
/// largest clean value**: the fill texture's encoded peak (215) lands exactly on white, no row
|
||||
/// clips, and every row scales by the same visible factor. (A first cut of 1.45 assumed a
|
||||
/// linear-space multiply and flattened six rows against white — the very gradient change the
|
||||
/// director banned.)
|
||||
const LIT_BOOST: f32 = 255.0 / 215.0;
|
||||
@@ -264,6 +264,32 @@ fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `OnValueChanged` for a bar the ENGINE moved rather than Lua — the nameplate health bars, whose
|
||||
/// value the plate driver re-sets from the unit's descriptor each time it changes (decision 2148).
|
||||
///
|
||||
/// The reference fires the same script from the same place: the plate's bar is driven by a
|
||||
/// GUID-watch callback (`0x7cc570`, registered `0x467e70`) that re-reads health and re-sets the bar
|
||||
/// synchronously on every server update, and `SetValue` is `SetValue`. pfUI hooks this
|
||||
/// (`nameplates.lua:393` `HookScript(healthbar, "OnValueChanged", …)`) and reads `this:GetParent()`
|
||||
/// inside the handler, so a driver that moved the value silently would leave it blind.
|
||||
pub(super) fn fire_engine_value_changed(lua: &Lua, bar: crate::widget::FrameHandle, value: f32) {
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.frame_id(bar)
|
||||
};
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
lua,
|
||||
id,
|
||||
"OnValueChanged",
|
||||
vec![Value::Number(f64::from(value))],
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua number-ish → f32 (nil/other → 0.0), for the color-form arguments.
|
||||
fn num_f32(v: &Value) -> f32 {
|
||||
match v {
|
||||
|
||||
@@ -28,6 +28,7 @@ mod model_clock;
|
||||
mod modelframe;
|
||||
mod movable;
|
||||
mod name_targets;
|
||||
mod nameplate;
|
||||
mod object_model;
|
||||
mod reference_surface;
|
||||
mod region_map;
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
//! The nameplate ABI, as the four vanilla addons actually read it (decision 2148).
|
||||
//!
|
||||
//! These are not "does the pool work" tests. Each is a transcription of a real corpus idiom — the
|
||||
//! same walk, the same destructuring, the same identification test — so a change that would break
|
||||
//! a shipped addon fails **here** rather than on the director's screen.
|
||||
//!
|
||||
//! Sources, all `## Interface: 11200`: `pfUI/modules/nameplates.lua` (+ `compat/vanilla.lua`),
|
||||
//! `ShaguTweaks/libs/libnameplate.lua`, `CustomNameplates/CustomNameplates.lua`,
|
||||
//! `_Nameplates/_Nameplates.lua`. The byte side is wow-re
|
||||
//! `system/ui/scratch/nameplate-lua-surface.md` (§5, 2026-09-09).
|
||||
|
||||
use crate::script::{PlateGeometry, PlateState, UiScript};
|
||||
|
||||
/// A VM with a `WorldFrame` to hang plates off — the only thing [`UiScript::sync_nameplates`]
|
||||
/// requires of a session.
|
||||
fn vm() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
/// The plate size at the capture window (`vplates` scenario: one gx unit = 1280 px, so the
|
||||
/// 0.1 × 0.025 plate is 128 × 32 px, and at seam scale 1.0 that is 128 × 32 units).
|
||||
fn geometry() -> PlateGeometry {
|
||||
PlateGeometry {
|
||||
width: 128.0,
|
||||
height: 32.0,
|
||||
bar_off_x: 4.0,
|
||||
bar_off_y: 4.0,
|
||||
bar_width: 103.0,
|
||||
bar_height: 9.0,
|
||||
level_off_x: 11.8,
|
||||
level_off_y: 9.1,
|
||||
skull_size: 12.8,
|
||||
raid_size: 25.6,
|
||||
name_height: 12.8,
|
||||
level_height: 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn plate(name: &str, health: f32, max: f32) -> PlateState {
|
||||
PlateState {
|
||||
// The unit's GUID stands in as a hash of its name: what matters to these tests is that the
|
||||
// same unit keeps the same key across frames and two units never share one.
|
||||
key: name.bytes().map(u64::from).sum(),
|
||||
top_centre: (500.0, 400.0),
|
||||
health,
|
||||
max_health: max,
|
||||
bar_colour: [1.0, 0.0, 0.0],
|
||||
name: name.to_string(),
|
||||
level: Some(12),
|
||||
level_colour: [1.0, 1.0, 0.0],
|
||||
raid_icon: None,
|
||||
alpha: 1.0,
|
||||
lit: false,
|
||||
hovered: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive N plates and settle the layout, the way one app frame does.
|
||||
fn drive(s: &mut UiScript, states: &[PlateState]) {
|
||||
s.sync_nameplates(geometry(), states);
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// pfUI + ShaguTweaks share one lineage: `GetNumChildren` grew, so walk the tail.
|
||||
///
|
||||
/// `initialized` is never reset in either addon, so a client whose child list SHRANK or REORDERED
|
||||
/// would make them miss every plate created after the old high-water mark — permanently, and with
|
||||
/// no error. This is the test that a retired plate keeps its slot.
|
||||
#[test]
|
||||
fn the_worldframe_child_list_only_grows_and_keeps_its_order() {
|
||||
let mut s = vm();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
2
|
||||
);
|
||||
|
||||
// One unit leaves: the plate is HIDDEN, and the child list does not shrink.
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
2
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local a, b = WorldFrame:GetChildren() return a:IsShown() == 1 and b:IsShown() == nil"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// A third unit arrives: it takes the pooled plate back — the count goes to 2, not 3.
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Wolf", 30.0, 40.0), plate("Boar", 5.0, 50.0)],
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(s.nameplate_pool_len(), 2);
|
||||
}
|
||||
|
||||
/// pfUI's `IsNamePlate` (`nameplates.lua:70`) and ShaguTweaks' (`libnameplate.lua:12`), verbatim in
|
||||
/// shape: the object type is `"Button"`, and region #1 is a `Texture` whose `GetTexture()` is
|
||||
/// exactly `Interface\Tooltips\Nameplate-Border`.
|
||||
///
|
||||
/// `CustomNameplates` runs the texture half against **every** WorldFrame child
|
||||
/// (`CustomNameplates.lua:93`), so `GetRegions()` must also be safe to call on a frame with none.
|
||||
#[test]
|
||||
fn a_plate_identifies_by_button_plus_the_border_texture() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local plate = WorldFrame:GetChildren()
|
||||
if plate:GetObjectType() ~= "Button" then return false end
|
||||
local region = plate:GetRegions()
|
||||
if not region or not region.GetTexture then return false end
|
||||
if region:GetObjectType() ~= "Texture" then return false end
|
||||
return region:GetTexture() == "Interface\\Tooltips\\Nameplate-Border"
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The two tuples, positionally — the ABI decision 2148 §3 records.
|
||||
///
|
||||
/// `CustomNameplates.lua:212` binds `Border, Glow, Name, Level, Boss, RaidTargetIcon`; pfUI and
|
||||
/// ShaguTweaks bind the same six by the `NAMEPLATE_OBJECTORDER` table. A **seventh** region is a
|
||||
/// bug (pfUI blanks any extra with `SetTexture("")`), and a **second** child is a bug too (pfUI
|
||||
/// destructures `healthbar, castbar` and would blank ours as a TBC cast bar).
|
||||
#[test]
|
||||
fn the_region_and_child_tuples_are_six_and_one() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:GetNumRegions()")
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local p = WorldFrame:GetChildren() return p:GetNumChildren()")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local p = WorldFrame:GetChildren()
|
||||
local border, glow, name, level, levelicon, raidicon = p:GetRegions()
|
||||
return border:GetTexture() == "Interface\\Tooltips\\Nameplate-Border"
|
||||
and glow:GetTexture() == "Interface\\Tooltips\\Nameplate-Glow"
|
||||
and name:GetObjectType() == "FontString"
|
||||
and level:GetObjectType() == "FontString"
|
||||
and levelicon:GetTexture() == "Interface\\TargetingFrame\\UI-TargetingFrame-Skull"
|
||||
and raidicon:GetTexture() == "Interface\\TargetingFrame\\UI-RaidTargetingIcons"
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
// ShaguTweaks `libnameplate.lua:39`: `plate.healthbar = plate:GetChildren()` — one child, and
|
||||
// it is the StatusBar. Its own single region is the fill `GetStatusBarTexture()` hands back.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local p = WorldFrame:GetChildren()
|
||||
local healthbar = p:GetChildren()
|
||||
if healthbar:GetObjectType() ~= "StatusBar" then return false end
|
||||
if healthbar:GetNumRegions() ~= 1 then return false end
|
||||
local fill = healthbar:GetStatusBarTexture()
|
||||
return fill:GetTexture() == "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill"
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `_Nameplates.Nameplate.Initialize` (`_Nameplates.lua:163`) is the strictest consumer: it
|
||||
/// content-addresses the regions and **validates the two FontString anchors**, rejecting any plate
|
||||
/// whose name is not `BOTTOM`→`CENTER` and whose level is not `CENTER`→`BOTTOMRIGHT`. Those are the
|
||||
/// reference's own (ctor `0x7cb456`, bind `0x7cb7f5`).
|
||||
#[test]
|
||||
fn the_two_fontstring_anchors_are_what_nameplates_validates() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local plate = WorldFrame:GetChildren()
|
||||
local Name, Level
|
||||
for _, region in ipairs({ plate:GetRegions() }) do
|
||||
if region:GetObjectType() == "FontString" then
|
||||
local point, _, relativePoint = region:GetPoint()
|
||||
if point == "BOTTOM" and relativePoint == "CENTER" then Name = region end
|
||||
if point == "CENTER" and relativePoint == "BOTTOMRIGHT" then Level = region end
|
||||
end
|
||||
end
|
||||
return Name ~= nil and Level ~= nil
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The bar reports **raw** health, not a fraction — `GetValue 0x78f5d0` → `[bar+0x320]`, and
|
||||
/// `GetMinMaxValues` → `(0, maxHealth)`. pfUI (`:595`) and CustomNameplates (`:471`) both divide,
|
||||
/// so a fraction here would read as full health on every plate.
|
||||
///
|
||||
/// This one corrected wow-re's own note: `nameplate-vkey.md` §4 had recorded `0x783380`, which is
|
||||
/// the internal fill *fraction*, as the Lua getter.
|
||||
#[test]
|
||||
fn the_healthbar_reports_raw_health_and_its_max() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local healthbar = WorldFrame:GetChildren():GetChildren()
|
||||
local min, max = healthbar:GetMinMaxValues()
|
||||
return healthbar:GetValue() == 30 and min == 0 and max == 40
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The plate is anonymous. `_Nameplates.lua:164` rejects any WorldFrame child with a name, and
|
||||
/// pfUI's bubble filter does the same — a named plate is invisible to both.
|
||||
#[test]
|
||||
fn a_plate_is_anonymous() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>("return WorldFrame:GetChildren():GetName() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The pooling contract: a retired plate comes back as the **same Lua object**. Every addon keys
|
||||
/// its per-plate state off exactly that identity — pfUI's `registry[plate]` (`:342`),
|
||||
/// `_Nameplates.Frames[Nameplate]` (`:205`) — and re-runs `OnCreate` for an object it has not seen.
|
||||
#[test]
|
||||
fn a_retired_plate_returns_as_the_same_lua_object() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
s.run("FirstPlate = WorldFrame:GetChildren()").unwrap();
|
||||
drive(&mut s, &[]);
|
||||
drive(&mut s, &[plate("Bear", 10.0, 90.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>("return WorldFrame:GetChildren() == FirstPlate")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// **A plate belongs to its UNIT, not to this frame's sort order.**
|
||||
///
|
||||
/// The driver hands plates over in distance-sorted order — the seat priority the reference solves
|
||||
/// overlap in — and that order changes whenever two units cross. If the pool were indexed by it,
|
||||
/// widget #1 would swap units mid-session, and every addon that caches state on the frame would be
|
||||
/// reading the other unit's: pfUI's `nameplate.original.name`/`level`, its `registry[plate]`
|
||||
/// bookkeeping, `_Nameplates`' per-plate button. So the pool is keyed by the unit's GUID, and this
|
||||
/// is the test that says so.
|
||||
#[test]
|
||||
fn a_plate_belongs_to_its_unit_across_a_sort_order_swap() {
|
||||
let mut s = vm();
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
WolfPlate = nil
|
||||
for _, p in ipairs({ WorldFrame:GetChildren() }) do
|
||||
local _, _, name = p:GetRegions()
|
||||
if name:GetText() == "Wolf" then WolfPlate = p end
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return WolfPlate ~= nil").unwrap());
|
||||
|
||||
// The two units cross: the driver now sorts Bear first. Nothing about either plate's identity
|
||||
// may move with it.
|
||||
drive(
|
||||
&mut s,
|
||||
&[plate("Bear", 10.0, 90.0), plate("Wolf", 30.0, 40.0)],
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local _, _, name = WolfPlate:GetRegions()
|
||||
return name:GetText() == "Wolf"
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `plate:GetAlpha() == 1` is how pfUI (`:879`) and CustomNameplates (`:287`) detect the TARGET
|
||||
/// plate: with a target up, every other plate is dimmed. So alpha is a contract, not a look.
|
||||
#[test]
|
||||
fn the_target_plate_is_the_opaque_one() {
|
||||
let mut s = vm();
|
||||
let mut target = plate("Wolf", 30.0, 40.0);
|
||||
let mut other = plate("Bear", 10.0, 90.0);
|
||||
target.alpha = 1.0;
|
||||
other.alpha = 0.5;
|
||||
drive(&mut s, &[target, other]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local a, b = WorldFrame:GetChildren() return a:GetAlpha() == 1 and b:GetAlpha() < 1"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// pfUI hooks the bar's `OnValueChanged` (`:393`) and reads `this:GetParent()` inside the handler
|
||||
/// to find the plate. The driver moves that value from Rust, so the handler has to fire from there
|
||||
/// too — the reference's own bar is driven by a GUID-watch callback and fires it the same way.
|
||||
#[test]
|
||||
fn an_engine_health_write_fires_onvaluechanged_with_the_plate_as_parent() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
s.run(
|
||||
r#"
|
||||
Fired = 0
|
||||
ParentIsPlate = false
|
||||
local plate = WorldFrame:GetChildren()
|
||||
local healthbar = plate:GetChildren()
|
||||
healthbar:SetScript("OnValueChanged", function()
|
||||
Fired = Fired + 1
|
||||
ParentIsPlate = (this:GetParent() == plate)
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
drive(&mut s, &[plate("Wolf", 12.0, 40.0)]);
|
||||
assert_eq!(s.eval::<i64>("return Fired").unwrap(), 1);
|
||||
assert!(s.eval::<bool>("return ParentIsPlate").unwrap());
|
||||
// Unmoved health fires nothing: the driver writes only what changed.
|
||||
drive(&mut s, &[plate("Wolf", 12.0, 40.0)]);
|
||||
assert_eq!(s.eval::<i64>("return Fired").unwrap(), 1);
|
||||
}
|
||||
|
||||
/// **An addon takes the plate over.** pfUI blanks all six regions (`DisableObject` →
|
||||
/// `SetTexture("")`) and paints its own art; ShaguTweaks reparents the raid icon. A driver that
|
||||
/// re-stamped every property every frame would undo that at 60 Hz — so static properties are
|
||||
/// written once, at creation, and never again.
|
||||
#[test]
|
||||
fn the_driver_does_not_overwrite_an_addons_takeover() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
s.run(
|
||||
r#"
|
||||
local plate = WorldFrame:GetChildren()
|
||||
for _, region in ipairs({ plate:GetRegions() }) do
|
||||
if region.SetTexture then region:SetTexture("") end
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Several frames of ordinary driving — health moving, the unit renamed, the plate re-seated.
|
||||
for hp in [29.0, 28.0, 27.0] {
|
||||
let mut state = plate("Wolf", hp, 40.0);
|
||||
state.top_centre = (500.0 + hp, 400.0);
|
||||
drive(&mut s, &[state]);
|
||||
}
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local border = WorldFrame:GetChildren():GetRegions()
|
||||
return border:GetTexture() == "" or border:GetTexture() == nil
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// pfUI reads `glow:IsShown()` as the MOUSEOVER signal (`:601`, `:880`). benilla does not paint the
|
||||
/// additive rim (0184 — it read as hard edge lines on our pipeline, and the director pinned a bar
|
||||
/// brighten instead), but the region is structurally present and really shown, because the
|
||||
/// deviation is about pixels and this is about data.
|
||||
#[test]
|
||||
fn the_glow_region_is_the_mouseover_signal() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local _, glow = WorldFrame:GetChildren():GetRegions() return glow:IsShown() == nil"
|
||||
)
|
||||
.unwrap());
|
||||
let mut hovered = plate("Wolf", 30.0, 40.0);
|
||||
hovered.hovered = true;
|
||||
drive(&mut s, &[hovered]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local _, glow = WorldFrame:GetChildren():GetRegions() return glow:IsShown() == 1"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The level number and the skull share one seat and are mutually exclusive — `level = None` is
|
||||
/// how the driver says "world boss, or ten levels up". pfUI gates on `levelicon:IsShown()` and
|
||||
/// CustomNameplates on `Boss:IsVisible()`, so both flags have to move.
|
||||
#[test]
|
||||
fn the_skull_replaces_the_level_number() {
|
||||
let mut s = vm();
|
||||
let mut skulled = plate("Wolf", 30.0, 40.0);
|
||||
skulled.level = None;
|
||||
drive(&mut s, &[skulled]);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local _, _, _, level, levelicon = WorldFrame:GetChildren():GetRegions()
|
||||
return level:IsShown() == nil and levelicon:IsShown() == 1
|
||||
and levelicon:IsVisible() == 1
|
||||
"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// An addon's own frame parented to `WorldFrame` shares the list with the plates —
|
||||
/// `_NameplatesFrame` and `pfUICombatScreen` both are — which is why the identification test in
|
||||
/// §2 is load-bearing and why the count-grew heuristic re-scans. It must not be mistaken for one.
|
||||
#[test]
|
||||
fn an_addon_frame_under_the_worldframe_is_not_a_plate() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
s.run(r#"CreateFrame("Frame", "pfUICombatScreen", WorldFrame)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
r#"
|
||||
local found = 0
|
||||
for _, frame in ipairs({ WorldFrame:GetChildren() }) do
|
||||
local region = frame:GetRegions()
|
||||
if frame:GetObjectType() == "Button" and region and region.GetTexture
|
||||
and region:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" then
|
||||
found = found + 1
|
||||
end
|
||||
end
|
||||
return found
|
||||
"#
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// **The plate does NOT claim the UI pointer — not yet, and deliberately.**
|
||||
///
|
||||
/// The reference's plate is a mouse-enabled `Button`: hovering it publishes the mouseover
|
||||
/// (`0x7cb850` OnEnter → `0x492890`) and a click selects the unit through the button's own click
|
||||
/// slot. benilla cannot simply set that bit, because on this engine the UI pointer pass owning a
|
||||
/// frame is what makes `PointerOverUi` true, and `target::hover::update_hover` returns early on
|
||||
/// exactly that — so a mouse-enabled plate with no engine-side seam takes the hover and gives
|
||||
/// nothing back: no model brighten, no lit bar, and no selection on click, because the click never
|
||||
/// reaches the world path either.
|
||||
///
|
||||
/// So the bit stays off until the seam exists (2148 §8), and this test is the tripwire: turning it
|
||||
/// on without wiring the hover and click through the engine fails here, in the ABI suite, rather
|
||||
/// than under the director's cursor.
|
||||
#[test]
|
||||
fn a_plate_does_not_yet_claim_the_ui_pointer() {
|
||||
let mut s = vm();
|
||||
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
|
||||
// The plate's TOP-CENTRE is (500, 400) and it hangs 32 units below, so this is its middle.
|
||||
// `mouse_move` speaks the same y-up FrameXML space the anchors do.
|
||||
//
|
||||
// The WorldFrame itself IS a legitimate hit here — it is mouse-enabled by construction, and
|
||||
// the app filters exactly that one id out (`input::feed_ui_input`, decision 1983) so camera
|
||||
// look, world clicks and hover targeting stay live over it. So the assertion mirrors the
|
||||
// app's own filter: whatever the pointer lands on must be the WorldFrame, never a plate.
|
||||
let focus = s.mouse_move(500.0, 384.0);
|
||||
assert!(
|
||||
focus.is_none_or(|id| s.is_world_frame(id)),
|
||||
"a plate claimed the UI pointer: the hover and click seams (2148 §8) have to land in the \
|
||||
same change that enables the mouse, or plate hover and click-select both die"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user