pfUI's hdgraphic runs, the plates settle, and the water camera comes out

- pfUI's hdgraphic module runs: SetWorldDetail and GetWorldDetail are real verbs with the reference's three stops, and the near clip plane is a live setting at 0.100 yd rather than a frozen 0.111 (2163)
- nameplates stop drawing a black box on hover or click, stop jittering a frame behind a moving creature, and a unit that walks into range later gets its plate; a unit with no level wears no skull, and the wheel zooms over a plate (2168)
- the faint one-pixel box around every letter of a magnified overhead name is gone, each glyph now carrying its own transparent padding; interface text drawn at 1:1 is byte-identical (2169)
- the camera's water collision comes back out for now: its pivot corridor dropped the camera a yard in one frame at head depth, which is the snapping reported around the surface while swimming (2165)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 21:02:02 +07:00
co-authored by Claude Fable 5.1
parent 6de042d5fa
commit 0a3c0692b1
41 changed files with 1643 additions and 831 deletions
+25 -14
View File
@@ -1068,21 +1068,32 @@
-- `benillaRow` is ours: the reference addresses its sliders as OptionsFrameSlider1..9, a
-- numbering our page does not use, so each row names the child it drives instead.
--
-- NOT bound, and named rather than faked: pfUI's module goes on to hook `SetWorldDetail`
-- and `GetWorldDetail` (both unbound) and drive `ConsoleExec("frillDensity ...")`,
-- `lodDist`, `nearClip`, `maxLOD`. `ConsoleExec` itself IS bound (1948 — it writes a
-- registered CVar or pushes a console command the app dispatches), and `frillDensity` is
-- now registered over the clutter knob (2151), so THAT write lands; `lodDist`, `nearClip`
-- and `maxLOD` have no benilla knob behind them and reach nothing, because registering a
-- CVar nothing reads would be exactly the silent pretence 1203 exists to forbid.
-- **Both WorldDetail verbs are bound now** (2163), which is what makes pfUI's module RUN
-- rather than merely load: it hooks `SetWorldDetail`/`GetWorldDetail`, and hooking a nil
-- took its extended arm down at the first call. `SetWorldDetail` is `0x488dd0`'s carve —
-- truncate toward zero, `[0, 2]` or raise `"value must be in the range 0, 2"`, write the
-- `0x804518` stops {16, 32, 48} — and `GetWorldDetail` is `0x488d70` answering the stop.
-- They live in `benilla_ui::script::cvars`, not here; this page still drives the CVar
-- directly, so nothing on it calls either. The stock file WILL, once 1751 migrates this
-- window: `OptionsFrame_Save`/`_Load` prefer `getglobal("Set"..func)` / `("Get"..func)`
-- over `SetCVar`/`GetCVar` (`OptionsFrame.lua` l.110/135/206-226).
--
-- Neither WorldDetail verb is called at load, so the module loads and its slider write
-- lands. Its EXTENDED arm still cannot RUN here — that arm is `SetWorldDetail`, and its
-- replacement `GetWorldDetail` calls a nil hook at any stop <= 48. Nothing in benilla
-- calls either today (this page drives the CVar directly), which is what keeps that
-- unreachable; the migration this file is scaffolding for is what changes it, since the
-- stock `OptionsFrame_Load` prefers `getglobal("Get"..func)` over `GetCVar(func)`
-- (`OptionsFrame.lua` l.110/135). Binding the pair is named as the next move in 2151.
-- **The nine CVars pfUI's `hdgraphic` drives through `ConsoleExec`, each pinned** (2163;
-- `ConsoleExec` itself is bound since 1948 — it writes a registered CVar or pushes a
-- console command the app dispatches). Registered here, so the write lands:
-- `frillDensity` (2151, the clutter knob) and `nearClip` (2163, the camera near plane —
-- `0x511bd4` re-stamps `[cam+0x38]` from its record every frame, so it was never the dead
-- knob our own const claimed). Registered in the REFERENCE but with no benilla reader, so
-- they reach nothing here and registering them would be 1203's silent pretence:
-- `lodDist` (`0x688524`, "100.0" — its `[0x867960]` IS read at `0x6afb1d`, the doodad LOD
-- swap we do not have), `footstepBias` (`0x6888b4`, "0.125" — alive at `0x68fcb6`, but a
-- derived constant here with nothing to honour a write), `mapObjLightLOD` (`0x6886ec`,
-- "0") and `SkyCloudLOD` (`0x6d1d33`, "0"). Registered there and DEAD there — one writer,
-- no reader image-wide, so the reference renders identically whatever they say (wow-re
-- `cvar/scratch/graphics-cost-cvar-census.md` §8): `DistCull` (`0x688570`) and
-- `texLodBias` (`0x6885e2`, whose sink `0x672640` is literally `ret 4`). And `maxLOD` is
-- **not a CVar at all** — zero of the 214 `CVar::Register 0x63db90` sites name it — so
-- pfUI's `ConsoleExec("maxLOD " .. arg)` reaches nothing in the real client either.
OptionsFrameSliders = {
{ text = "UI Scale", func = "uiscale",
minValue = 0.64, maxValue = 1.0, valueStep = 0.01, benillaRow = "RowUiScale" },
+11 -5
View File
@@ -47,7 +47,7 @@ use benilla_ui::script::keybind::{AddonBindingBody, KeybindCommand, KeybindReque
use benilla_ui::script::UiScript;
use crate::char_select::ClientState;
use crate::ui_script::{PlayerUiHover, PointerOverUi, UiKeyboardCapture};
use crate::ui_script::{PlayerUiHover, PointerOverUiPanel, UiKeyboardCapture};
pub(crate) mod chord;
pub(crate) mod commands;
@@ -425,7 +425,8 @@ fn latch_and_dispatch(
scroll: Res<AccumulatedMouseScroll>,
capture: Res<UiKeyboardCapture>,
hover: Res<PlayerUiHover>,
over_ui: Res<PointerOverUi>,
// The CHROME flag — its only reader here is the wheel branch, which says why.
over_ui: Res<PointerOverUiPanel>,
dispatch: Res<BindingDispatch>,
mut state: ResMut<BindingsState>,
mut same_vm: Local<crate::ui_script::VmMemo<bool>>,
@@ -638,6 +639,11 @@ fn latch_and_dispatch(
scroll.delta.y / bevy::input::mouse::MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
}
};
// **Over CHROME**, not over any UI at all: the wheel still zooms with the cursor on a
// nameplate, which is a mouse-enabled widget and not a panel (`PointerOverUiPanel`). Plates sit
// over exactly the things you look at, so the raw flag silently killed scroll-zoom wherever one
// happened to be — a regression of the day the plate became a widget (2148), found in the
// 2168 audit.
if wheel != 0.0 && !armed && !sup && !over_ui.0 {
let (key, amount) = if wheel > 0.0 {
(BindKey::WheelUp, wheel)
@@ -847,7 +853,7 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin))
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.init_resource::<BindingsState>()
.insert_resource(BindingDispatch::test_defaults())
.add_systems(Update, latch_and_dispatch);
@@ -902,7 +908,7 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::input::InputPlugin))
.init_resource::<UiKeyboardCapture>()
.init_resource::<PlayerUiHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.init_resource::<BindingsState>()
.init_resource::<BindingDispatch>()
.add_systems(Update, (sync_dispatch, latch_and_dispatch).chain());
@@ -1521,7 +1527,7 @@ mod tests {
app.update();
assert!(state(&app).fired(cmd::CAMERA_ZOOM_IN));
assert_eq!(state(&app).amount(cmd::CAMERA_ZOOM_IN), 2.0);
app.world_mut().resource_mut::<PointerOverUi>().0 = true;
app.world_mut().resource_mut::<PointerOverUiPanel>().0 = true;
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
@@ -701,7 +701,7 @@ mod tests {
Mat4::perspective_infinite_reverse_rh(
std::f32::consts::FRAC_PI_4,
3200.0 / 1800.0,
benilla_world::view::CAM_NEAR,
benilla_world::view::NEARCLIP_DEFAULT,
)
}
+10 -1
View File
@@ -15,7 +15,7 @@ const WOLF_FACTION: u32 = 32;
/// The `name-water` fixture's unit: the same wolf, re-seated 25 yd along the water scenario's own
/// look bearing (`WATER_EYE` → `WATER_LOOK`) at the river surface, so its overhead name projects
/// onto the water *beyond* it.
const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4];
pub(super) const NAME_WATER_POS: [f32; 3] = [-9512.97, -331.29, 61.4];
/// The lighting matrix's chest (decision 0744): `GameObjectDisplayInfo` 259,
/// `World\SkillActivated\Containers\TreasureChest01.mdx`. GameObject guids carry the `0xF110` high
@@ -1356,6 +1356,15 @@ pub(super) fn seed_ui_fixture(
}
UiFixture::NameWater => {
use benilla_protocol::messages::ObjectFields;
// The subject is an NPC's overhead name, and `UnitNameNPC` registers "0" (1804's
// byte-read default), so without this the shot contains no name at all — which is
// exactly what it had contained since 1804 landed. Set through Lua, the way a player
// turns it on, so the sync drains it into `NameConfig` like any other CVar write.
if let Some(script) = script.as_deref_mut() {
if let Err(e) = script.run("SetCVar(\"UnitNameNPC\", \"1\")") {
warn!("capture: name fixture could not enable UnitNameNPC: {e}");
}
}
// The synthetic self player at the eye (the reaction lookup reads its store, and the
// name colour is that verdict).
const SELF_GUID: u64 = 0x51;
+32 -1
View File
@@ -790,6 +790,37 @@ impl Plugin for CapturePlugin {
minute: knob("WOW_VISTA_MIN", 720.0) as u32,
ui: None,
}
} else if name == "name-close" {
// The magnified overhead-name instrument (see `scenarios::NAME_CLOSE_AT`): the
// `name-water` wolf, orbited by knob and looked straight at, so the ONE variable
// is how many device pixels a glyph texel is drawn into. Not a golden scenario.
let knob = |k: &str, d: f32| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let at = scenarios::NAME_CLOSE_AT;
// The name hangs `h` above the unit's FEET (the posed PlayerName attachment plus
// the block's own line of rise) — that point is what the camera orbits and aims at.
let name_at = [at[0], at[1], at[2] + knob("WOW_NAME_H", 1.4)];
let (dist, az, el) = (
knob("WOW_NAME_DIST", 4.0),
knob("WOW_NAME_AZ", 124.0).to_radians(),
knob("WOW_NAME_EL", 8.0).to_radians(),
);
Scenario {
name: "name-close",
map: Some(scenarios::MAP_AZEROTH),
eye: [
name_at[0] + dist * el.cos() * az.cos(),
name_at[1] + dist * el.cos() * az.sin(),
name_at[2] + dist * el.sin(),
],
look: name_at,
minute: 720,
ui: Some(UiFixture::NameWater),
}
// By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is
// narrowed — every old viewpoint is still capturable by name (decision 0632).
} else if let Some(&s) = SCENARIOS
@@ -806,7 +837,7 @@ impl Plugin for CapturePlugin {
.map(|s| s.name)
.collect();
eprintln!(
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?}, {glue_known:?} (or fxview, waterfx)"
"WOW_CAPTURE={name:?} is not a known scenario; choose one of: {known:?}, {glue_known:?} (or fxview, waterfx, name-close)"
);
std::process::exit(2);
})
+19 -3
View File
@@ -1013,9 +1013,12 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
ui: Some(UiFixture::Bare),
},
// The V-key nameplate over a synthetic Timber Wolf, framed like the reference screenshot
// (an eye-height look at a wolf ~8 yd off, Northshire ground). Plates draw through the
// UiQuads overlay, which renders in every capture — no WOW_CAPTURE_UI needed. Run with
// `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window).
// (an eye-height look at a wolf ~8 yd off, Northshire ground). **Plates are real widgets since
// 2148**, so they need the UI VM and a `WorldFrame` to hang off — which this scenario's own
// `ui:` fixture supplies (it is what makes `run_mode::capture_ui_opted_in` true). The older
// note here said the opposite ("plates draw through the UiQuads overlay… no WOW_CAPTURE_UI
// needed"), which was the painter's truth and stopped being true the day they became frames.
// Run with `WOW_CAPTURE=vplates` (main.rs sizes this window 1024×768 — the 1:1 gx window).
Scenario {
name: "vplates",
map: Some(MAP_AZEROTH),
@@ -1199,6 +1202,19 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
},
];
/// The `name-close` instrument's subject: the `name-water` wolf's overhead NAME, orbited at an
/// arbitrary distance. **World text is the only consumer that draws the glyph sheet at anything
/// but 1:1** — the 2-D path rasterizes at the size it draws, texel for pixel, by construction — so
/// a defect in how a glyph CELL is sampled can only appear in a magnified name, and nothing
/// photographed one: `name-water`'s 25 yd MINIFIES the same glyphs, which hides exactly this.
///
/// Not a golden scenario (its output is the knobs', not the name's), same as `fxview`/`vista`:
/// `WOW_NAME_DIST` (yd from the name, default 4), `WOW_NAME_AZ` (compass bearing the eye stands
/// on, degrees, 0 = +X), `WOW_NAME_EL` (eye elevation above the name, degrees), `WOW_NAME_H` (the
/// name's height above the unit's feet, yd — the anchor this aims at). The camera looks straight
/// at the name, so the shot is centred and the magnification is `WOW_NAME_DIST` alone.
pub(super) const NAME_CLOSE_AT: [f32; 3] = super::fixtures::NAME_WATER_POS;
/// The Deeprun Tram undersea tube — see the `tram-undersea` scenario. Raw WoW coords; the Subway
/// WMO is the map's global `MODF` at the origin with identity rotation, so these are also its
/// model-space coords.
+93 -58
View File
@@ -326,6 +326,30 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `GetCVar("uiscale")`, and both read 0.9 there on every window we ship against.
same("useUiScale", "0"),
same("farclip", "350"),
// **`nearclip` — farclip's other half, and a knob we had been holding as a constant** (2163).
// `0x68867a` passes name `0x84ffb0` `"nearclip"`, default string `0x84fb48` `"0.1"`, help
// "Near clip plane distance", flags `1`, callback `0x688d90`, record `[0xc7f348]` (wow-re
// `re/cvar/cvar-register-sites.tsv` row 187).
//
// **The reader is the camera, and it re-reads every frame.** `0x511bc0` — the per-frame camera
// outer, sole caller `0x483094` — stamps `[cam+0x38]` from this record's float before the
// `[cam+0x48]` branch and unconditionally: `511bcf mov eax,[0xbe1078]; 511bd4 fld [eax+0x24];
// 511bdc fstp [esi+0x38]`, with `[0xbe1078]` the handle `0x50b728` caches from a `"nearclip"`
// Lookup. `farclip` is the next four instructions. `benilla_world::view::stamp_near_clip` is
// that, and `ViewDistance` is the pair.
//
// **Why it was not registered for so long, and why that reasoning was wrong.** The near plane
// was a `CAM_NEAR = 1.0/9.0` const documented as the reference's own, on the true finding that
// the callback's *derived global* `[0xc7b480]` has one writer and no readers (wow-re
// `cvar/scratch/graphics-cost-cvar-census.md` §8 lists `nearclip` among the eleven dead knobs
// for exactly that). The camera does not read that global; it reads the record. So the ctor's
// `0x3de38e39` = 1/9 is overwritten by the first frame's stamp and never reaches a picture —
// a verified-but-partial mechanism, which the contract §4 names as the classic trap.
//
// pfUI's `hdgraphic` writes it (`ConsoleExec("nearClip " .. arg*2/100)`, 0.06..0.30 across its
// extended stops) — every value inside the reference's own `[0.01, 0.33]`, which is why that
// module could ask for it.
same("nearclip", "0.1"),
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
// Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the
// reference behaviors benilla already shipped (empty-world click clears the target; no
@@ -693,25 +717,6 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `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
@@ -1383,6 +1388,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"soundzonemusicnodelay" => knobs.sound.zone_music_no_delay = v != 0.0,
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
// The reference REFUSES an out-of-range write here rather than clamping (`0x688d90` echoes
// "NearClip must be in range 0.01 - 0.33" and returns 0). We clamp, which is this table's
// standing posture for every range — the consumer clamps at its own edge.
"nearclip" => knobs.view.set_nearclip(v),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"autoselfcast" => knobs.auto_self_cast.0 = v != 0.0,
"assistattack" => knobs.assist_attack.0 = v != 0.0,
@@ -1446,10 +1455,6 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"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,
@@ -1833,7 +1838,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 87] = [
let session: [(&str, String); 84] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1856,6 +1861,7 @@ fn sync_cvars(
("SoundZoneMusicNoDelay", flag(sound.zone_music_no_delay)),
("uiScale", scale.0.to_string()),
("farclip", view.farclip.to_string()),
("nearclip", view.nearclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
("autoSelfCast", flag(auto_self_cast.0)),
("assistAttack", flag(assist_attack.0)),
@@ -1877,19 +1883,6 @@ fn sync_cvars(
"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",
@@ -2340,6 +2333,14 @@ mod tests {
// ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the
// env-less 350 literal (view.rs doc: "Default 350" — the reference's own, 1624).
assert_eq!(d["farclip"], 350.0);
// `nearclip` welds to the const the off-world spawners use, so the viewer, the depth probe
// and the player's camera cannot open on three different near planes again (2163).
assert_eq!(d["nearclip"], benilla_world::view::NEARCLIP_DEFAULT);
assert_eq!(
d["nearclip"],
ViewDistance::default().nearclip,
"the registered default and the resource's own must be one number"
);
// Same shape as farclip: `MsaaSetting::default()` reads $WOW_MSAA, so the registered
// default mirrors the env-less literal — 1, the reference's own (1629).
assert_eq!(d["gxMultisample"], 1.0);
@@ -2400,28 +2401,12 @@ mod tests {
"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
// The camera options weld to `CameraOptions::default()` the same way (2149) — and the one
// that matters here is the one 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!(camera_opts.pivot, "the binary registers cameraPivot \"1\"");
assert_eq!(d["cameraTerrainTilt"] != 0.0, camera_opts.terrain_tilt);
assert!(
!camera_opts.terrain_tilt,
@@ -2497,7 +2482,10 @@ mod tests {
fn apply_parses_clamps_and_reports_unknowns() {
let mut sound = SoundConfig::default();
let mut scale = UiScaleCvar(0.9);
let mut view = ViewDistance { farclip: 350.0 };
let mut view = ViewDistance {
farclip: 350.0,
nearclip: benilla_world::view::NEARCLIP_DEFAULT,
};
let mut look = LookConfig::default();
let mut click = ClickConfig::default();
let mut loot = LootConfig::default();
@@ -2607,6 +2595,17 @@ mod tests {
assert_eq!(knobs.sound.master, 1.0);
assert!(apply_to_knobs("farclip", "50", &mut knobs));
assert_eq!(knobs.view.farclip, *FARCLIP_RANGE.start());
// `nearclip` clamps to the reference's own callback bounds `[0.01, 0.33]` (`0x688d90`),
// both ends. pfUI's extended stops write 0.06..0.30, so its whole range passes untouched.
assert!(apply_to_knobs("nearclip", "0.001", &mut knobs));
assert_eq!(
knobs.view.nearclip, 0.01,
"[0x8029d0], the callback's low bound"
);
assert!(apply_to_knobs("nearclip", "9", &mut knobs));
assert_eq!(knobs.view.nearclip, 0.33, "[0x808300], its high bound");
assert!(apply_to_knobs("nearclip", "0.3", &mut knobs));
assert_eq!(knobs.view.nearclip, 0.3);
// Multisampling clamps to the reference's own [1, 16] and takes an int the way its `atoi`
// does — the value reaching the camera is a sample COUNT, where 1 is none (1629).
assert!(apply_to_knobs("gxMultisample", "4", &mut knobs));
@@ -2764,6 +2763,39 @@ mod tests {
assert!(apply_to_knobs(key, "48", &mut knobs));
assert_ne!(knobs.clutter.density, 0.5, "{key}: reached no knob");
}
// **The engine verbs' half of the same weld** (2163). `SetWorldDetail`/`GetWorldDetail` live
// in `benilla-ui`, which cannot see this table, so the two CVar names and the stop table it
// writes are consts there — and if either name stopped being registered, or the reference's
// {16, 32, 48} stopped being `frillDensity`'s unit times the stop, the verbs would write
// into nothing and only this assertion would say so.
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_WORLD_DETAIL));
assert!(REGISTERED
.iter()
.any(|r| r.name == benilla_ui::script::CVAR_FRILL_DENSITY));
for (n, frill) in benilla_ui::script::WORLD_DETAIL_STOPS.iter().enumerate() {
assert_eq!(
*frill,
benilla_formats::FRILL_DENSITY * (n as u32 + 1),
"stop {n}: the reference's own 0x804518 entry must be this knob's unit times the stop"
);
// Either spelling of the stop lands on the same ground cover — which is what lets the
// setter write `frillDensity` and the getter read `WorldDetail` without disagreeing.
assert!(apply_to_knobs("WorldDetail", &n.to_string(), &mut knobs));
let by_stop = knobs.clutter.density;
knobs.clutter.density = 0.5;
assert!(apply_to_knobs(
"frillDensity",
&frill.to_string(),
&mut knobs
));
assert_eq!(
knobs.clutter.density, by_stop,
"stop {n}: the two spellings disagree"
);
assert_eq!(knobs.clutter.frill_density(), *frill as f32);
}
// Back to the shipped stop, so the rows after this one read the default ground cover.
assert!(apply_to_knobs("WorldDetail", "1", &mut knobs));
assert_eq!(knobs.clutter.density, 2.0);
@@ -2869,7 +2901,10 @@ mod tests {
app.add_plugins(bevy::MinimalPlugins)
.insert_resource(SoundConfig::default())
.insert_resource(UiScaleCvar(DEFAULT_UI_SCALE))
.insert_resource(ViewDistance { farclip: 350.0 })
.insert_resource(ViewDistance {
farclip: 350.0,
nearclip: benilla_world::view::NEARCLIP_DEFAULT,
})
.insert_resource(MsaaSetting { samples: 1 })
// Literal for the same reason (1642): TexFilterSetting::default() reads
// $WOW_TRILINEAR / $WOW_ANISO. These are what ships (1645).
+1 -1
View File
@@ -39,7 +39,7 @@ fn log_path() -> Option<String> {
}
}
/// Is the recorder on? Read once — the phase marks in `drive_script` consult this every frame.
/// Is the recorder on? Read once — the phase marks in the UI pass (`extract::tick_script`/`paint_script`) consult this every frame.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| log_path().is_some())
+2 -2
View File
@@ -1,7 +1,7 @@
//! The HUD minimap renderer (decision 0203 phase 1) — the app half of the `<Minimap>` widget.
//!
//! The engine side (benilla-ui) carries the widget's rect + zoom and emits a
//! `QuadContent::Minimap` hole at the frame's own draw slot; `ui_script::extract::drive_script` parks that
//! `QuadContent::Minimap` hole at the frame's own draw slot; `ui_script::extract::paint_script` parks that
//! in [`MinimapWidget`], and [`emit_minimap`] (in the [`UiQuadAppend`] window) fills it: the
//! streamed tile window around the player, clipped to the widget rect and masked to the
//! `MinimapMask.blp` circle at present time, with the player arrow rotating on top. Children of
@@ -210,7 +210,7 @@ fn probe_minimap_widget(mut widget: ResMut<MinimapWidget>, windows: Query<&Windo
});
}
/// This frame's extracted `<Minimap>` widget slot, written by `ui_script::extract::drive_script` (the
/// This frame's extracted `<Minimap>` widget slot, written by `ui_script::extract::paint_script` (the
/// `QuadContent::Minimap` arm) — `None` when no Minimap widget is visible (cluster hidden, no XML).
#[derive(Resource, Default)]
pub(crate) struct MinimapWidget(pub(crate) Option<MinimapSlot>);
+19 -74
View File
@@ -13,16 +13,14 @@ 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 super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt};
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
use crate::ui_script::PointerOverUi;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
use benilla_world::model_fade::{
self_model_fade_alpha, FadeMaterials, PendingAppearFade, RenderFade, SELF_FADE_WINDOW,
};
use benilla_world::view::CAM_NEAR;
/// The reference's up-edge click predicate, in **camera degrees and milliseconds** — the whole
/// orbit-vs-select law (decision 1122; wow-re `world-click-drag-arbitration.md`, §5 fan-out
@@ -796,9 +794,6 @@ pub(crate) struct CameraControl {
/// `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,
@@ -927,9 +922,9 @@ impl WorldMouse {
/// gesture the world already has.
pub(super) fn latch_world_mouse(
buttons: Res<ButtonInput<MouseButton>>,
pointer_over_ui: Res<PointerOverUi>,
// **A V-plate is UI the camera looks straight through** (decision 2148). Plates became real
// mouse-enabled widgets, so the pointer inside one makes `PointerOverUi` true — which is right
// **The CHROME flag, not the raw one** — a V-plate is UI the camera looks straight through
// (decision 2159, and the concept is named on `PointerOverUiPanel`). Plates became real
// mouse-enabled widgets in 2148, so the pointer inside one makes `PointerOverUi` true — right
// for the world PICK (the plate publishes the mouseover itself, and its click selects) and
// wrong for the camera: a drag that begins over a plate has to turn the view, or nameplates
// become dead patches you cannot swing the camera from. The reference says the same thing from
@@ -938,7 +933,7 @@ pub(super) fn latch_world_mouse(
//
// The click does NOT leak through with it: `PlayerUiClickConsumed` still suppresses the
// `WorldClick`/`WorldRightClick` below, so a plate click stays the plate's own.
plate_hover: Res<crate::vplates::PlateHover>,
pointer_over_ui: Res<crate::ui_script::PointerOverUiPanel>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
@@ -946,7 +941,7 @@ pub(super) fn latch_world_mouse(
let Ok(camera) = cameras.single() else {
return;
};
let over_ui = pointer_over_ui.0 && plate_hover.0.is_none();
let over_ui = pointer_over_ui.0;
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
}
@@ -1143,14 +1138,6 @@ pub(super) fn run_look_session(
// 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,
@@ -1217,7 +1204,6 @@ pub(super) fn seat_on_subject(
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
@@ -1226,28 +1212,6 @@ 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
@@ -1256,23 +1220,6 @@ 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
@@ -1315,15 +1262,6 @@ pub(super) fn seat_on_subject(
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,
@@ -1473,10 +1411,11 @@ pub(super) fn seat_camera(
// (verified negative, wow-re `water-frame-straddle` §4a: zero liquid-height queries in the
// camera TU); the no-straddle experience is the *submersion probe's* — the frame flips
// submerged the moment the lowest near-plane corner reaches the surface
// (`liquid::detect_submersion`, the corner-min probe), and with [`CAM_NEAR`] at the
// reference's 1/9 the whole crossing band is a few inches tall. 0905's eye snap — the local
// compensation for the old 1.0-yd near plane — is removed with its cause (its record is
// superseded; see the 0905-successor decision).
// (`liquid::detect_submersion`, the corner-min probe), and with the near plane at the
// `nearclip` CVar's registered 0.1 the whole crossing band is a few inches tall (2163 — it
// said "the reference's 1/9" until the per-frame re-stamp at `0x511bd4` was read). 0905's eye
// snap — the local compensation for the old 1.0-yd near plane — is removed with its cause
// (its record is superseded; see the 0905-successor decision).
// `WOW_CAM_DUMP=frame`: the REALIZED pose, per frame, bit-exact — not the pose that was asked for.
//
// Every scripted probe sets `yaw`/`pitch`/`distance` and we then reason as though the camera is
@@ -1534,8 +1473,14 @@ pub(super) fn seat_camera(
// 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((seated - pivot).length(), CAM_NEAR, SELF_FADE_WINDOW);
//
// The LIVE `nearclip`, not a constant (2163): the fade is defined as finishing where the near
// plane starts cutting, so a player who moves that plane has to move this with it.
rig.self_fade_alpha = self_model_fade_alpha(
(seated - pivot).length(),
dynamics.nearclip,
SELF_FADE_WINDOW,
);
}
/// Apply the self-avatar zoom-in fade ([`CameraControl::self_fade_alpha`], computed in [`control`]) to
+35 -14
View File
@@ -194,20 +194,6 @@ impl SmoothChannel {
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 {
@@ -236,6 +222,41 @@ impl SmoothChannel {
}
}
/// **Sweep a regime switch and bound its step** — the check decision 2165 exists because this tree
/// did not have one.
///
/// A mechanism that switches between *regimes* — liquid bands, a slope staircase, a state ladder,
/// an eligibility gate — is not verified by point assertions inside each regime. Those are
/// structurally blind to a cliff *between* them, which is how a pivot corridor that moved the
/// camera's framing point 1.04 yd in one frame shipped behind five green tests, each correct.
/// Walk the parameter across every boundary instead and bound the step.
///
/// Where a jump is intended, `max_jump` is where its size gets **written down** — which is the
/// second reason to reach for this rather than eyeball a sweep: the bound is the claim.
#[cfg(test)]
pub(super) fn assert_bounded_step(
(from, to): (f32, f32),
step: f32,
max_jump: f32,
mut f: impl FnMut(f32) -> f32,
) {
let mut previous: Option<(f32, f32)> = None;
let steps = ((to - from) / step).ceil() as i32;
for i in 0..=steps {
let x = (from + step * i as f32).min(to);
let y = f(x);
if let Some((px, py)) = previous {
assert!(
(y - py).abs() <= max_jump,
"a step from {px} to {x} moved the output {py} -> {y} \
({:+}), past the bound {max_jump}",
y - py
);
}
previous = Some((x, y));
}
}
#[cfg(test)]
mod tests {
use super::*;
+84 -478
View File
@@ -30,21 +30,6 @@ pub(crate) const PIVOT_DY_MIN_DEFAULT: f32 = 0.0;
/// zero at once the pivot lets go (`90.0`, `[0xbe0fc8]`).
pub(crate) const TARGET_SMOOTH_SPEED_DEFAULT: f32 = 90.0;
/// The lower band threshold — `[0x8089d0] = 2/9`. Also the height the framing pivot is floored at
/// **above the liquid surface** while the surface band holds.
const BAND_NEAR: f32 = 2.0 / 9.0;
/// The upper band threshold — `[0x8089d4] = 5/9`. Past it neither band claims the subject.
const BAND_FAR: f32 = 5.0 / 9.0;
/// `cameraSurfaceFinalPitch`'s registered default, DEGREES in the reference's own sign (positive =
/// looking down) — `[0xbe0f2c]`, `"5.0"`.
pub(crate) const SURFACE_FINAL_PITCH_DEFAULT: f32 = 5.0;
/// `cameraSubmergeFinalPitch`'s registered default, same units — `[0xbe0fdc]`, `"5.0"`.
pub(crate) const SUBMERGE_FINAL_PITCH_DEFAULT: f32 = 5.0;
/// `cameraPitchSmoothSpeed`'s registered default, deg/s — `[0xbe0ce4]`, `"45.0"`. The rate the
/// pitch channel arms at (`0x512830`'s `duration = |Δ| / (rate · π/180)`).
pub(crate) const PITCH_SMOOTH_SPEED_DEFAULT: f32 = 45.0;
/// `cameraGroundSmoothSpeed`'s registered default, deg/s — `[0xbe0fc0]`, `"7.5"`.
pub(crate) const GROUND_SMOOTH_SPEED_DEFAULT: f32 = 7.5;
/// `cameraTerrainTiltTimeMin`'s registered default, seconds — `[0xbe1050]`, `"3.0"`.
@@ -86,14 +71,6 @@ pub(crate) struct CameraOptions {
pub(crate) pivot_dy_min: f32,
/// `cameraTargetSmoothSpeed` — see [`TARGET_SMOOTH_SPEED_DEFAULT`].
pub(crate) target_smooth_speed: f32,
/// `cameraWaterCollision` — registered **"1"**, the second of the four that ships ON.
pub(crate) water_collision: bool,
/// `cameraSurfaceFinalPitch` — see [`SURFACE_FINAL_PITCH_DEFAULT`].
pub(crate) surface_final_pitch: f32,
/// `cameraSubmergeFinalPitch` — see [`SUBMERGE_FINAL_PITCH_DEFAULT`].
pub(crate) submerge_final_pitch: f32,
/// `cameraPitchSmoothSpeed` — see [`PITCH_SMOOTH_SPEED_DEFAULT`].
pub(crate) pitch_smooth_speed: f32,
/// `cameraTerrainTilt` — registered **"0"**, so Follow Terrain is OFF out of the box.
pub(crate) terrain_tilt: bool,
/// `cameraGroundSmoothSpeed`, deg/s — the ground channel's rate (`[0xbe0fc0]`, `"7.5"`).
@@ -120,10 +97,6 @@ impl Default for CameraOptions {
pivot_dx_max: PIVOT_DX_MAX_DEFAULT,
pivot_dy_min: PIVOT_DY_MIN_DEFAULT,
target_smooth_speed: TARGET_SMOOTH_SPEED_DEFAULT,
water_collision: true,
surface_final_pitch: SURFACE_FINAL_PITCH_DEFAULT,
submerge_final_pitch: SUBMERGE_FINAL_PITCH_DEFAULT,
pitch_smooth_speed: PITCH_SMOOTH_SPEED_DEFAULT,
terrain_tilt: false,
ground_smooth_speed: GROUND_SMOOTH_SPEED_DEFAULT,
tilt_time_min: TILT_TIME_MIN_DEFAULT,
@@ -137,76 +110,6 @@ impl Default for CameraOptions {
}
}
/// **Which liquid band the camera's subject is in** — `[cam+0x90]`'s bits `0x100000`/`0x200000`,
/// whose sole writer image-wide is the classifier `0x511ad0` (wow-re `pivot-height-glide.md` §5,
/// re-confirmed by `camera-cvar-gates.md` §4b's census).
///
/// The classifier is not a depth test on the *eye* or on the *feet*: it compares the liquid
/// surface against **the framing pivot's target height**, i.e. against roughly where the subject's
/// head is going to be. That is why the bands read as "the swimmer's head is at the line" and "the
/// line is over the head" rather than as water depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(super) enum LiquidBand {
/// Neither bit: dry, or the surface is far enough over the pivot that nothing claims it.
#[default]
None,
/// `0x100000` — the surface at, below, or barely above the pivot target.
Surface,
/// `0x200000` — the surface moderately above it.
Submerged,
}
impl LiquidBand {
/// `0x511ad0`'s classification, from the liquid surface height at the subject (`None` = dry),
/// the subject's feet and the pivot channel's target — all in world Y.
///
/// The reference tests `d < [cam+0x1c8]` **or** `d − [cam+0x1c8] < 2/9` for the surface band;
/// the first disjunct is subsumed by the second (a pivot target is always positive — it is
/// floored at `5/6`), so it is written once here. Everything else is verbatim, including the
/// "neither" leg, which is a real state and not a fallthrough: a subject deep enough under the
/// surface leaves the pivot corridor alone entirely.
pub(super) fn classify(surface_y: Option<f32>, feet_y: f32, pivot_target: f32) -> Self {
let Some(surface_y) = surface_y else {
return Self::None;
};
let excess = (surface_y - feet_y) - pivot_target;
if excess < BAND_NEAR {
Self::Surface
} else if excess < BAND_FAR {
Self::Submerged
} else {
Self::None
}
}
/// **The pivot corridor** this band imposes (`0x50e570`'s `local_8` floor and `local_c` cap,
/// `0x50e756`/`0x50e767`), given the liquid's height above the subject's feet and the pivot
/// channel's live value. Unobstructed, the solver's chain reconstructs the live height exactly,
/// so the whole of the liquid's effect on the framing pivot is `live.clamp(floor, cap)`.
///
/// - **Surface**: floor `2/9 + d`, cap `max(live, floor)` — the pivot is pushed UP to sit a
/// fixed `2/9` yd above the waterline, which is what keeps a swimmer's head framed.
/// - **Submerged**: floor the ordinary `5/6`, cap `max(d − 5/6, 5/6)` — the pivot is pulled
/// DOWN, kept below a surface that has gone over the head.
/// - **None**: the ordinary corridor, i.e. the live height untouched.
///
/// Gated by `cameraWaterCollision` at the call site, not here: `0x50e5ec` reads the CVar once
/// and the bands only reach `local_8`/`local_c` through that read.
pub(super) fn pivot_corridor(self, d: f32, live: f32) -> (f32, f32) {
match self {
Self::Surface => {
let floor = BAND_NEAR + d;
(floor, live.max(floor))
}
Self::Submerged => (
super::camera::CAM_PIVOT_FLOOR,
(d - super::camera::CAM_PIVOT_FLOOR).max(super::camera::CAM_PIVOT_FLOOR),
),
Self::None => (super::camera::CAM_PIVOT_FLOOR, live),
}
}
}
/// The camera-dynamics inputs one frame hands the rig — the knobs, plus the facts about the
/// *followed unit* that the mechanisms' gates need and the camera does not hold.
#[derive(Clone, Copy, Debug)]
@@ -217,33 +120,11 @@ pub(super) struct DynamicsInput {
/// far-sight-adjusted copy.
pub(super) smooth_style: super::camera::FollowStyle,
pub(super) subject: SubjectState,
/// Filled in by [`super::camera::seat_on_subject`], which is where the far-sight substitution
/// lives — the controller builds this input with the default (dry) and never has to know.
pub(super) liquid: SubjectLiquid,
}
/// The liquid the followed subject is standing in, as the camera needs it: the surface height in
/// world Y (`None` = dry) and the feet it is measured from. Resolved by the controller, which is
/// where the world query and the far-sight substitution both live.
///
/// **Every liquid, not only water** — the reference's `0x511ad0` queries `0x670630` over the unit's
/// own liquid handle with no kind filter, and benilla's rule since decision 0634 is the same: you
/// swim in lava and slime too, so the camera frames you in them the same way.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct SubjectLiquid {
pub(super) surface_y: Option<f32>,
pub(super) feet_y: f32,
}
impl SubjectLiquid {
/// This frame's band and the liquid's height above the feet — `(band, d)`, the pair
/// [`LiquidBand::pivot_corridor`] takes.
pub(super) fn band(&self, pivot_target: f32) -> (LiquidBand, f32) {
(
LiquidBand::classify(self.surface_y, self.feet_y, pivot_target),
self.surface_y.map_or(0.0, |y| y - self.feet_y),
)
}
/// The live `nearclip` ([`benilla_world::view::ViewDistance::nearclip`]) — the self-avatar
/// fade's reference plane, carried on this bundle rather than as a fourteenth `seat_camera`
/// argument. It rides here because it is a camera CVar reaching a camera kernel, which is what
/// this struct is for (2163).
pub(super) nearclip: f32,
}
/// What the four mechanisms' gates need to know about the **followed unit** — the conjuncts that
@@ -292,123 +173,6 @@ impl SubjectState {
/// below read as the reference's masks do).
use crate::creature_anim::move_flags as mf;
/// **`cameraWaterCollision`'s second consumer** — the band-CROSSING pitch kick (`0x50ecae` inside
/// the driver `0x50e8d0`; wow-re `camera-cvar-gates.md` §4b, VERIFIED).
///
/// **It is an EDGE detector, and that is the correction the §5 round exists for.** `0x50eb14`
/// snapshots the *previous* frame's `[cam+0x90]` band bits into two locals **before**
/// `0x50eb45 call 0x511ad0`, which unconditionally clears both and re-sets at most one; the two
/// tests at `0x50ecc2`/`0x50ecdf` then reload the field **fresh**. So the pair is crossed:
///
/// | leg | condition | CVar |
/// |---|---|---|
/// | surface | OLD submerged **and** NEW surface | `cameraSurfaceFinalPitch` `"5.0"` |
/// | submerge | OLD surface **and** NEW submerged | `cameraSubmergeFinalPitch` `"5.0"` |
///
/// A client that fires on the *level* of the current bit re-arms the channel every frame the
/// camera spends in the band; the reference fires once per crossing. The target is an **absolute**
/// pitch, not an increment — the name is *Final*Pitch — and a target of exactly `0.0` is refused
/// outright (`0x50ed0c jnp`), which is reachable only if a player zeroes one of the two CVars.
///
/// **`[cam+0x90] & 1` is FREELOOK — the player is holding mouse-look** (`0x50fe41 or eax,1` in
/// `0x50fe30`, cleared at `0x50fddd`; named at the bytes by the two mode strings `"Camera
/// FREELOOK"` / `"Camera NORMAL"` handed to `0x44cb20`). `camera-cvar-gates.md` §7 listed bit 0 as
/// unsettled; the round this work dispatched settled it. So `0x50ed0e test cl,1` reads *"is the
/// hand on the camera"*, and the crossing **hard-snaps** while it is and eases when it is not —
/// which is the right way round: a player already turning the camera is not interrupted by a
/// 45 °/s tween, and one who is not gets the smooth one.
///
/// **The pitch axis, and the divergence that turned out not to be one** (wow-re
/// `camera-cvar-kernels.md` Q5, dispatched by this work). The reference's mouse pitch and yaw are
/// **immediate**: `0x510120` adds the delta to all three of the channel's fields — live
/// `[cam+0xf4]`, start `[cam+0x1e4]` and target `[cam+0x1e0]` — and holds **zero** references to
/// `[cam+0x90]`, so it never arms the tween; `0x50f160`'s ease runs only behind the armed bit
/// `0x2000000`. So benilla's direct write to `cam.pitch` is what the reference does, and the three
/// `cameraSmooth*` CVars that looked like they might say otherwise gate a different system
/// entirely (the auto-return, `0x510760`/`0x510850`).
///
/// What that lockstep buys, and what benilla owes it, is [`Self::nudge`]: `+0xf4` is *the* live
/// pitch with a tween **overlay**, not a channel apart from it, so a drag during a `*FinalPitch`
/// ease moves both endpoints by the same delta and the ease **keeps its remaining travel**. It is
/// not cancelled by the player's hand — it is carried by it.
pub(super) struct WaterPitch {
/// The band the **previous** frame classified — the whole selector (`0x50eb14`'s snapshot).
/// Updated every frame whatever the CVar says, exactly where the reference snapshots it.
previous: LiquidBand,
/// The kick in flight, over `cam.pitch`. Angular; `in_flight` is the reference's `0x2000000`.
kick: SmoothChannel,
/// Is the channel currently authoring the pitch? Distinct from `in_flight` only on the frame
/// the tween lands, where the final value still has to be written.
driving: bool,
}
impl Default for WaterPitch {
fn default() -> Self {
Self {
previous: LiquidBand::None,
kick: SmoothChannel::angular(),
driving: false,
}
}
}
impl WaterPitch {
/// One frame: take the band this frame classified, fire on a crossing, and return the pitch
/// the channel wants — `None` when it is not driving, which is every frame out of the water.
///
/// `pitch` is the camera's live pitch in **benilla's sign** (positive = up); the two CVars are
/// in the reference's degrees (positive = down), so the target is negated here, once, where it
/// can be read next to the reason.
pub(super) fn advance(
&mut self,
band: LiquidBand,
pitch: f32,
freelook: bool,
cfg: &CameraOptions,
dt: f32,
) -> Option<f32> {
let previous = std::mem::replace(&mut self.previous, band);
if cfg.water_collision {
let target_deg = match (previous, band) {
(LiquidBand::Submerged, LiquidBand::Surface) => Some(cfg.surface_final_pitch),
(LiquidBand::Surface, LiquidBand::Submerged) => Some(cfg.submerge_final_pitch),
_ => None,
};
// Exactly zero is refused, not armed at zero — `0x50ed09 test ah,0x44 / jnp`.
if let Some(deg) = target_deg.filter(|d| *d != 0.0) {
let target = -deg.to_radians();
self.kick.snap(pitch);
if freelook {
// `0x50ed13`: with the hand on the camera the target is written straight into
// the live pitch AND the channel target — a hard snap, no easing.
self.kick.snap(target);
} else {
self.kick
.arm(&Arm::at(target, cfg.pitch_smooth_speed.to_radians()));
}
self.driving = true;
}
}
if !self.driving {
return None;
}
let live = self.kick.advance(dt);
if !self.kick.in_flight() {
self.driving = false;
}
Some(live)
}
/// A mouse pitch delta, carried into the channel — `0x510120`'s lockstep write of all three
/// fields. A kick in flight keeps its remaining travel and lands `delta` further along; with
/// nothing in flight this is a no-op on a channel nobody is reading.
pub(super) fn nudge(&mut self, delta: f32) {
if self.driving {
self.kick.shift(delta);
}
}
}
/// **`cameraPivot` — "smart pivot"**: the camera the collision solver has pinned against geometry
/// tilts its *view* instead of swinging its *arm*.
///
@@ -1114,243 +878,6 @@ mod tests {
);
}
/// `0x511ad0`'s three bands, at their two thresholds and either side of each. The classifier
/// compares the liquid against the **pivot target**, not against the feet, so the same water
/// depth bands differently for a tauren and a gnome — which is the point, and is why a test
/// that only varied the water level would pass on a wrong reading.
#[test]
fn the_liquid_bands_are_the_two_thresholds_around_the_pivot_target() {
let pivot = 1.9_f32; // a human's neck height
let at = |excess: f32| LiquidBand::classify(Some(10.0 + pivot + excess), 10.0, pivot);
assert_eq!(at(-5.0), LiquidBand::Surface, "waist deep");
assert_eq!(at(0.0), LiquidBand::Surface, "exactly at the pivot");
assert_eq!(at(BAND_NEAR - 0.01), LiquidBand::Surface);
assert_eq!(at(BAND_NEAR + 0.01), LiquidBand::Submerged);
assert_eq!(at(BAND_FAR - 0.01), LiquidBand::Submerged);
assert_eq!(
at(BAND_FAR + 0.01),
LiquidBand::None,
"deep enough for neither"
);
assert_eq!(at(50.0), LiquidBand::None);
assert_eq!(
LiquidBand::classify(None, 10.0, pivot),
LiquidBand::None,
"dry"
);
// **Exactly at each threshold**, which the case above cannot reach: `pivot + excess` does
// not survive an f32 round trip at world magnitudes, so the inclusivity is pinned on a
// zeroed pivot where the subtraction is exact. Both compares are `<`, so the threshold
// itself belongs to the band ABOVE it.
assert_eq!(
LiquidBand::classify(Some(BAND_NEAR), 0.0, 0.0),
LiquidBand::Submerged
);
assert_eq!(
LiquidBand::classify(Some(BAND_FAR), 0.0, 0.0),
LiquidBand::None
);
// A shorter subject in the same water is a band further along — the pivot target is the
// comparand, and this is the case a feet-relative reading gets wrong.
assert_eq!(
LiquidBand::classify(Some(10.0 + 1.5), 10.0, 0.9),
LiquidBand::None
);
assert_eq!(
LiquidBand::classify(Some(10.0 + 1.5), 10.0, pivot),
LiquidBand::Surface
);
}
/// The corridor the bands impose on the framing pivot: pushed UP to `2/9` above the waterline
/// while the surface band holds, pulled DOWN under a surface that has gone over the head, and
/// untouched otherwise. Out of liquid the corridor is the identity — the property that makes
/// this safe to run on every frame of a dry world.
#[test]
fn the_pivot_corridor_frames_over_the_waterline_and_is_the_identity_when_dry() {
let live = 1.9_f32;
let clamp = |band: LiquidBand, d: f32| {
let (floor, cap) = band.pivot_corridor(d, live);
live.clamp(floor, cap.max(floor))
};
// Dry: the live height, bit for bit.
assert_eq!(clamp(LiquidBand::None, 0.0), live);
// Surface, water over the head: the pivot rises to 2/9 above the line, so the framing
// stays out of the water instead of following the neck under it.
assert_eq!(clamp(LiquidBand::Surface, 2.0), 2.0 + BAND_NEAR);
// Surface, but the line is below `live − 2/9`: the floor does not bind and nothing moves.
// The corridor only ever RAISES on this band — it never pulls the pivot down to the water.
assert_eq!(clamp(LiquidBand::Surface, 1.4), live);
assert_eq!(clamp(LiquidBand::Surface, 0.2), live);
// Submerged: capped at d − 5/6, i.e. pulled below a surface that is well over the head.
assert_eq!(
clamp(LiquidBand::Submerged, 2.5),
2.5 - super::super::camera::CAM_PIVOT_FLOOR
);
// …and never below the ordinary pivot floor, however deep.
assert_eq!(
clamp(LiquidBand::Submerged, 0.1),
super::super::camera::CAM_PIVOT_FLOOR
);
}
/// **The crossing, not the level** (`camera-cvar-gates.md` §4b's Correction F): the kick fires
/// once on a band change and stays silent for every frame spent inside a band, which is the
/// difference between one ease and a channel re-armed sixty times a second.
#[test]
fn only_a_band_crossing_kicks_and_only_the_two_crossed_pairs_do() {
let cfg = CameraOptions::default();
let mut w = WaterPitch::default();
// Sixty frames of surface: the first is a None -> Surface change, which is NOT one of the
// two crossed pairs, so nothing fires; nor does any frame after it.
for _ in 0..60 {
assert_eq!(w.advance(LiquidBand::Surface, 0.0, false, &cfg, DT), None);
}
// Surface -> Submerged IS one, and it arms.
assert!(w
.advance(LiquidBand::Submerged, 0.0, false, &cfg, DT)
.is_some());
// Staying submerged does not re-arm — it only runs the tween already in flight out.
let mut ran = 0;
for _ in 0..600 {
if w.advance(LiquidBand::Submerged, 0.0, false, &cfg, DT)
.is_some()
{
ran += 1;
}
}
assert!(
ran > 0 && ran < 600,
"the tween ran and then stopped ({ran})"
);
// And the other crossed pair fires on the way back up.
assert!(w
.advance(LiquidBand::Surface, 0.0, false, &cfg, DT)
.is_some());
}
/// The target is ABSOLUTE — the name is *Final*Pitch — reached over `|Δ| /
/// cameraPitchSmoothSpeed`, and it is the reference's degrees NEGATED, because down is
/// positive there and up is positive here.
#[test]
fn the_kick_eases_to_the_final_pitch_in_benillas_own_sign() {
let cfg = CameraOptions::default();
let mut w = WaterPitch::default();
let from = 0.8_f32; // looking well up
w.advance(LiquidBand::Surface, from, false, &cfg, DT);
assert!(w
.advance(LiquidBand::Submerged, from, false, &cfg, DT)
.is_some());
let target = -cfg.submerge_final_pitch.to_radians();
let expected = (target - from).abs() / cfg.pitch_smooth_speed.to_radians();
let mut last = from;
let mut took = None;
for frame in 0..600 {
if let Some(p) = w.advance(LiquidBand::Submerged, last, false, &cfg, DT) {
last = p;
if took.is_none() && (p - target).abs() < CHANNEL_EPS {
took = Some(frame as f32 * DT);
}
}
}
assert!(
(last - target).abs() < CHANNEL_EPS,
"landed at {last}, wanted {target}"
);
let took = took.expect("arrives");
assert!(
(took - expected).abs() < 0.05,
"|Δ| / cameraPitchSmoothSpeed = {expected:.3}s, took {took:.3}s"
);
assert!(target < 0.0, "5° DOWN is negative in benilla's sign");
}
/// **FREELOOK hard-snaps** (`0x50ed0e test cl,1`, whose identity the §5 round settled): with
/// the hand on the camera the crossing lands the target in one frame instead of easing to it.
#[test]
fn a_crossing_under_mouse_look_snaps_instead_of_easing() {
let cfg = CameraOptions::default();
let target = -cfg.submerge_final_pitch.to_radians();
let mut held = WaterPitch::default();
held.advance(LiquidBand::Surface, 0.8, true, &cfg, DT);
assert_eq!(
held.advance(LiquidBand::Submerged, 0.8, true, &cfg, DT),
Some(target),
"freelook: there on the very frame of the crossing"
);
// The same crossing with the hand off takes several frames to get there.
let mut free = WaterPitch::default();
free.advance(LiquidBand::Surface, 0.8, false, &cfg, DT);
let first = free
.advance(LiquidBand::Submerged, 0.8, false, &cfg, DT)
.unwrap();
assert!(
(first - target).abs() > 0.1,
"not-freelook: still travelling, at {first}"
);
}
/// **A drag CARRIES an ease in flight, it does not cancel it** (`0x510120` moves all three of
/// the channel's fields by the same delta — Q5). The remaining travel is preserved: the ease
/// lands exactly `delta` past where it would have.
#[test]
fn a_mouse_pitch_during_the_ease_carries_it_rather_than_cancelling_it() {
let cfg = CameraOptions::default();
let target = -cfg.submerge_final_pitch.to_radians();
let nudge = 0.2_f32;
let run = |with_nudge: bool| {
let mut w = WaterPitch::default();
w.advance(LiquidBand::Surface, 0.8, false, &cfg, DT);
let mut last = w
.advance(LiquidBand::Submerged, 0.8, false, &cfg, DT)
.unwrap();
for frame in 0..600 {
if with_nudge && frame == 5 {
w.nudge(nudge);
}
match w.advance(LiquidBand::Submerged, last, false, &cfg, DT) {
Some(p) => last = p,
None => break,
}
}
last
};
let plain = run(false);
assert!((plain - target).abs() < CHANNEL_EPS);
assert!(
(run(true) - (target + nudge)).abs() < CHANNEL_EPS,
"the ease should land the drag's delta past its own target"
);
}
/// The two refusals: the CVar off fires nothing, and a target of exactly zero is refused
/// outright rather than armed at zero (`0x50ed0c jnp`).
#[test]
fn the_cvar_and_an_exactly_zero_target_both_refuse_the_kick() {
let off = CameraOptions {
water_collision: false,
..CameraOptions::default()
};
let mut w = WaterPitch::default();
w.advance(LiquidBand::Surface, 0.0, false, &off, DT);
assert_eq!(w.advance(LiquidBand::Submerged, 0.0, false, &off, DT), None);
// …and the previous band is still tracked while it is off, so turning it back on mid-swim
// does not invent a crossing out of the frame it was toggled.
let on = CameraOptions::default();
assert_eq!(w.advance(LiquidBand::Submerged, 0.0, false, &on, DT), None);
let zeroed = CameraOptions {
submerge_final_pitch: 0.0,
..CameraOptions::default()
};
let mut w = WaterPitch::default();
w.advance(LiquidBand::Surface, 0.0, false, &zeroed, DT);
assert_eq!(
w.advance(LiquidBand::Submerged, 0.0, false, &zeroed, DT),
None
);
}
/// The staircase, not a curve (`0x808a40` walked downward with no interpolation) — and the
/// **five** reachable steps, because the ±20° clamp equals record 4 and the table has one
/// consumer image-wide, so records 5–9 can never show through.
@@ -1790,4 +1317,83 @@ mod tests {
}
assert_eq!(p.bias(), mid, "the return was cancelled, not completed");
}
/// **The sweep decision 2165 says every regime switch owes** — here across the staircase's own
/// four keys, in both signs.
///
/// Two claims, and the pair is the point. The staircase itself steps by exactly 5° and never
/// more: that jump is the mechanism, and this is where its size is written down. What reaches
/// the *camera* steps by a fortieth of that, because the staircase arms a channel instead of
/// being rendered — which is precisely what the water corridor did not do.
#[test]
fn the_staircases_five_degree_jumps_reach_the_camera_as_a_smooth_lean() {
use super::super::camera::FollowStyle;
use super::super::camera_channel::assert_bounded_step;
// The mechanism's own jump, bounded at itself.
assert_bounded_step(
(-0.6, 0.6),
0.0005,
5.0_f32.to_radians() + 1.0e-6,
TerrainTilt::slope_to_pitch,
);
// And what a player actually sees, walking terrain that sweeps every key in 20 s.
let cfg = CameraOptions {
terrain_tilt: true,
..CameraOptions::default()
};
let moving = SubjectState {
move_flags: mf::FORWARD,
..SubjectState::default()
};
let mut tilt = TerrainTilt::default();
assert_bounded_step((0.0, 20.0), DT, 0.25_f32.to_radians(), |now| {
let slope = -0.5 + now * 0.05;
tilt.advance(|| slope, true, &moving, FollowStyle::Smart, &cfg, DT)
});
}
/// The same sweep across head bob's two edges — the arm and the disarm — which are the only
/// places its offset can move discontinuously.
///
/// The arm is continuous by construction (`since_transition` resets, so the kernel's phase
/// starts at zero and both components with it); the disarm is the cosine ramp, whose peak rate
/// is what the bound is set from. A regression that armed at an arbitrary phase would put a
/// full amplitude into one frame and land here.
#[test]
fn arming_and_disarming_the_bob_never_steps_the_eye_by_a_visible_amount() {
use super::super::camera::follow_cmd as c;
use super::super::camera_channel::assert_bounded_step;
let cfg = CameraOptions {
bobbing: true,
..CameraOptions::default()
};
// A full amplitude in one frame would be this; the two bounds are fractions of it.
let amplitude = cfg.bob_ud_amplitude * BOB_AMPLITUDE_SCALE;
let mut bob = HeadBob::default();
let mut step = |now: f32| {
let command = if (1.0..4.0).contains(&now) {
c::FORWARD
} else {
0
};
bob.advance(
0.0,
&SubjectState {
command,
speed: 7.0,
..SubjectState::default()
},
&cfg,
DT,
);
bob.offset().length()
};
// Arming, and three seconds of bobbing.
assert_bounded_step((0.0, 3.9), DT, amplitude * 0.25, &mut step);
// The disarm's own ramp, bounded at what `cameraBobbingSmoothSpeed` actually buys.
assert_bounded_step((3.9, 6.0), DT, amplitude * 0.5, &mut step);
}
}
+5 -4
View File
@@ -38,6 +38,10 @@ pub(super) fn control(
Res<camera::FollowConfig>,
// The four 1.12 camera option toggles and their numeric siblings (decision 2149).
Res<camera_dynamics::CameraOptions>,
// `nearclip`/`farclip` (2163) — the camera's own clip pair. Only the near half is read
// here, for the self-avatar fade's reference plane; the far half is the wall's, read by
// the world.
Res<benilla_world::view::ViewDistance>,
),
// 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
@@ -212,6 +216,7 @@ pub(super) fn control(
// the mover.
let dynamics = camera_dynamics::DynamicsInput {
options: *pointer.4,
nearclip: pointer.5.nearclip,
smooth_style: pointer.3.style,
subject: camera_dynamics::SubjectState {
move_flags: player.move_flags,
@@ -242,8 +247,6 @@ pub(super) fn control(
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:
@@ -537,7 +540,6 @@ pub(super) fn control(
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.
@@ -1109,7 +1111,6 @@ pub(super) fn control(
cam_probe,
&follow,
&dynamics,
world,
);
// The cast bar's local self-cancel trigger (`ui_cast::local_self_cancel`): a fresh
+5 -2
View File
@@ -12,7 +12,7 @@ use benilla_assets::coords::wow_to_bevy;
use benilla_assets::{RenderConfig, WorldAssets};
use benilla_world::terrain_stream::SPAWN_XY;
use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, CAM_NEAR};
use benilla_world::view::{WorldCamera, CAM_FAR, CAM_FOVY, NEARCLIP_DEFAULT};
use super::{
CameraControl, CameraProbe, FlyCam, MoveSpeed, Player, PlayerCapsule, CAM_COLLISION_RADIUS,
@@ -104,7 +104,10 @@ pub(super) fn setup_player(
msaa.level(),
Projection::from(PerspectiveProjection {
far: cam_far,
near: CAM_NEAR,
// The registered default, and only for frame zero: `view::stamp_near_clip` re-stamps
// this from the live `nearclip` every frame, exactly as `0x511bc0` overwrites whatever
// the reference's camera ctor left in `[cam+0x38]` (2163).
near: NEARCLIP_DEFAULT,
fov: CAM_FOVY,
..default()
}),
+1 -1
View File
@@ -1052,7 +1052,7 @@ pub(crate) struct BoothPanes(pub(crate) HashMap<String, f32>);
/// Both directions of the booth↔UI bridge in one system param: the bake a bound region **samples**
/// ([`PortraitImages`]) and the pane geometry the extract **publishes** back ([`BoothPanes`]).
///
/// They travel together because they are the same seam, and because `drive_script` had already
/// They travel together because they are the same seam, and because the UI pass had already
/// reached Bevy's 16-parameter ceiling — two more `Res`es there is one too many.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct BoothBridge<'w> {
+3 -3
View File
@@ -20,7 +20,7 @@
//! before the capture is for.
//!
//! **And on our pipeline that `Hide()` lands one frame too late** — measured, not reasoned:
//! a live double press wrote the text straight into the second PNG. `ui_script`'s `drive_script`
//! a live double press wrote the text straight into the second PNG. `ui_script`'s paint pass
//! ticks the VM and builds this frame's UI quad list at the TOP of the `UiInput` set; the binding
//! dispatch that runs `TakeScreenshot()` sits at the BOTTOM of that same set. So by the time the
//! binding hides the frame, the quads carrying its text are already built, and the frame that then
@@ -28,7 +28,7 @@
//! otherwise; the real client simply does not build its draw list in that order.
//!
//! So [`ask_for_captures`] **holds each ask for one frame** ([`ScreenshotState::pending`]). The
//! capture is requested on the frame AFTER the press, whose `drive_script` has rebuilt the quads
//! capture is requested on the frame AFTER the press, whose `paint_script` has rebuilt the quads
//! with the frame hidden. One frame is the whole fix and also the minimum: any less and the stale
//! quads are what gets photographed, any more and the shutter drifts from the keypress for no
//! reason. `ui_script::screenshot_tests` pins the UI contract; this file's own test pins the
@@ -152,7 +152,7 @@ impl ScreenshotState {
///
/// The order within a frame is: spawn last frame's asks first, then drain this frame's. Both halves
/// matter. Spawning first means the entity is in the world before the render extract at the end of
/// THIS frame, whose UI quads `drive_script` rebuilt with the status line already hidden. Draining
/// THIS frame, whose UI quads `paint_script` rebuilt with the status line already hidden. Draining
/// second means an ask made by the binding dispatch earlier in this same frame waits its turn
/// rather than being photographed against the quads that were built before the hide — which is the
/// bug this deferral exists for (module docs).
+2 -2
View File
@@ -650,7 +650,7 @@ impl Plugin for UiModelsPlugin {
.init_non_send_resource::<TileState>()
.add_systems(Startup, setup_tiles)
// **Before the extract**, which is where a `<Model>` pane's tile request is
// published (`UiInput`'s `drive_script`) — see [`forget_dead_vm_tiles`].
// published (the UI pass's `paint_script`) — see [`forget_dead_vm_tiles`].
.add_systems(
Update,
forget_dead_vm_tiles.before(crate::ui_script::UiInput),
@@ -799,7 +799,7 @@ struct TileRender<'w> {
/// extract**, which is the whole reason it is not a first step inside [`sync_tiles`].
///
/// A logout, a login and a `ReloadUI()` all replace the VM in `PreUpdate`; the extract
/// (`ui_script`'s `drive_script`, in `UiInput`) then publishes the NEW tree's tile requests, and
/// (`ui_script`'s `paint_script`) then publishes the NEW tree's tile requests, and
/// `sync_tiles` reads them after that. Clearing the bridge from inside `sync_tiles` would
/// therefore throw away the new VM's very first publish — and the extract's conversion is
/// memoized on the engine's entry list, so an entry that does not change again is never converted
@@ -33,7 +33,7 @@ fn bar_color(s: &UiScript) -> (f64, f64, f64) {
.unwrap()
}
/// One tick of the app's real order (`drive_script`): OnUpdate, resolve, then the draw list.
/// One tick of the app's real order (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
+190 -58
View File
@@ -1,4 +1,4 @@
//! The extraction pass: [`drive_script`] turns [`UiScript::extract`]'s per-frame output into
//! The extraction pass: [`tick_script`] + [`paint_script`] turn [`UiScript::extract`]'s per-frame output into
//! [`UiQuads`] for the render pass (screen size → tick → resolve → extract → quads), including the
//! held-cursor icon overlay ([`cursor_icon_quad`]) — CAPTURE-ONLY since decision 0216 §5, where
//! the held payload's icon became the hardware cursor ([`crate::cursor`]) in a normal run; the
@@ -509,10 +509,21 @@ pub(crate) fn seat_text_measurer(script: &mut UiScript, atlas: &UiFontAtlas, sea
)));
}
/// Per frame: screen size → `tick` (OnUpdate) → `resolve` → `extract` → [`UiQuads`]. Script errors
/// drain to the log (throttled by being drained — each fires once).
/// **The UI pass, first half: the VM's frame** — screen size -> `tick` (OnUpdate) -> the measure
/// round-trip -> `resolve`. Script errors drain to the log (throttled by being drained, each fires
/// once). The quads are the second half's ([`paint_script`]).
///
/// **The pass is two systems because a widget can be anchored to the world** (decision 2168). The
/// hit test has to run before `WorldStage::Input` (`PlayerUiHover` feeds `PointerOverUi`, which the
/// camera reads), so this half stays there. Building the quads there too made every world-anchored
/// widget a frame stale, and since 2148 the nameplates ARE widgets — real `WorldFrame` children
/// positioned by `vplates::drive_vplates` from a camera that only exists later in the frame. The
/// plate was therefore painted at the unit's PREVIOUS frame's screen point: a median 1.9 px and a
/// p90 of 16 px of lag per frame at 640x360 while a guard walked (the `vpl` trace, 2026-09-10), and
/// proportionally more at a real window. That is the director's "way more jittered when the
/// creature is moving".
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn drive_script(
pub(super) fn tick_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
// REAL time, deliberately: this drives the VM's `GetTime()` session clock, and the reference
@@ -524,27 +535,11 @@ pub(super) fn drive_script(
// (verified live: an occluded run's UI clock fell 27 s behind in 43 s of wall time).
time: Res<Time<Real>>,
mut ui_clock: ResMut<super::UiClock>,
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
// The booth seam's DPI half, and the pane map this half invalidates when the VM or the window
// is gone — see the two early returns.
mut booths: crate::portrait::BoothBridge,
// Two facts about the RUN, tupled to stay inside Bevy's 16-element system-param limit (the
// same squeeze `player::control` and `GateInputs` above record):
// · the held-cursor icon quad (decision 0216 §5) is CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern);
// · whether anything wants this pass's phase split at all ([`super::UiCostWanted`]).
run: (
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// The append-lane hole this pass parks for its UiQuadAppend producer: the `<Minimap>` widget
// slot (`minimap::emit_minimap` fills it with tile/arrow quads — decision 0203 phase 1). (The
// autocast shine's sites used to ride beside it; the shine is a model tile since 2014.)
mut parked_minimap: ResMut<crate::minimap::MinimapWidget>,
ui_cost_wanted: Res<super::UiCostWanted>,
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
// invalidation below.
@@ -559,34 +554,12 @@ pub(super) fn drive_script(
mut last_dpi: Local<f32>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
// The conversion loop below is a pure function of (extracted, text_ui, the RASTER
// ENVIRONMENT, the portrait token map, the glyph-sheet generation) — the sprite caches are
// monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff would then
// discard. Capture mode never skips (the harness wants exact per-frame output, including the
// cursor-icon quad's live mouse position).
//
// The raster environment is window size × seam scale × **`scale_factor`**, and that last term
// is decision 1342's correction. A monitor hop at an unchanged window size moves nothing else
// in this list, but it changes the integer device-pixel size every glyph rasterizes at
// (`TextEngine::ppem`) — so the quads held from last frame are the wrong size and the gate
// must miss. (1339 caught the same hole through an atlas-bake counter, which was the right
// fix for a design where the atlas re-baked; there is nothing to re-bake now, so the term that
// survives is the one that actually moved.)
//
// The generation term is the glyph sheet RESETTING — the one event that moves a cached cell's
// UV, which happens when the sheet fills and is repacked from empty. Held quads carry the old
// UVs and would draw letters as fragments of other letters.
//
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
// VM lives for one login (decision 1290), so a fresh VM must never be gated on what the previous
// one extracted — its first frame is always a real conversion.
mut prev: Local<crate::ui_script::VmMemo<GateInputs>>,
// This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`).
mut ui_cost: ResMut<super::UiFrameCost>,
// What the paint half needs from this one — and whether it may run at all.
mut pass: ResMut<super::UiPassState>,
) {
let (capture, ui_cost_wanted) = run;
pass.live = false;
let Some(mut script) = script else {
// No VM ⇒ no UI is sampling any booth pane. The panes map must not outlive its writer:
// every OTHER return in this system provably keeps pane presence unchanged (the settled
@@ -596,7 +569,6 @@ pub(super) fn drive_script(
booths.panes.0.clear();
return;
};
let prev = prev.get(&script);
let Ok(window) = window.single() else {
// Same law as the no-VM arm: no window, no sampling — a pane map with no writer lies.
booths.panes.0.clear();
@@ -627,7 +599,6 @@ pub(super) fn drive_script(
let dpi = window.scale_factor();
// The tile renderer sizes its cells in device pixels off this (decision 2008).
booths.tiles.dpi = dpi;
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_dpi != dpi;
if seam_moved {
if *last_seam != 0.0 {
@@ -782,6 +753,139 @@ pub(super) fn drive_script(
warn!("ui_script: {w}");
}
// ── The handover ────────────────────────────────────────────────────────────────────────
// Everything the paint half derives from the window and the meter, published once here so the
// two halves cannot disagree about the frame they are in.
*pass = super::UiPassState {
live: true,
seam: s,
dpi,
us_tick,
us_resolve,
us_measure,
solves_before: solves_before.unwrap_or(0),
derives_before: derives_before.unwrap_or(0),
};
}
/// **The UI pass, second half: the quads** — a second (incremental) measure + `resolve`, the tree
/// walk, and the conversion into [`UiQuads`].
///
/// Split out of [`tick_script`] (decision 2168) and scheduled **after the camera and after the
/// plate driver**, which is the whole point: a widget positioned from this frame's camera has to be
/// walked after that camera exists. The re-resolve is what makes the plates' fresh anchors real —
/// it is the layout ledger's incremental pass, so on a frame where nothing but the plates moved it
/// solves the plates and nothing else.
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn paint_script(
script: Option<NonSendMut<UiScript>>,
window: Query<&Window, With<PrimaryWindow>>,
mut quads: ResMut<UiQuads>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Two facts about the RUN, tupled to stay inside Bevy's 16-element system-param limit (the
// same squeeze `player::control` and `GateInputs` above record):
// · the held-cursor icon quad (decision 0216 §5) is CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern);
// · whether anything wants this pass's phase split at all ([`super::UiCostWanted`]).
run: (
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// The append-lane hole this pass parks for its UiQuadAppend producer: the `<Minimap>` widget
// slot (`minimap::emit_minimap` fills it with tile/arrow quads — decision 0203 phase 1). (The
// autocast shine's sites used to ride beside it; the shine is a model tile since 2014.)
mut parked_minimap: ResMut<crate::minimap::MinimapWidget>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
// The conversion loop below is a pure function of (extracted, text_ui, the RASTER
// ENVIRONMENT, the portrait token map, the glyph-sheet generation) — the sprite caches are
// monotone path→handle, so equal inputs reproduce the same `UiQuads` the diff would then
// discard. Capture mode never skips (the harness wants exact per-frame output, including the
// cursor-icon quad's live mouse position).
//
// The raster environment is window size × seam scale × **`scale_factor`**, and that last term
// is decision 1342's correction. A monitor hop at an unchanged window size moves nothing else
// in this list, but it changes the integer device-pixel size every glyph rasterizes at
// (`TextEngine::ppem`) — so the quads held from last frame are the wrong size and the gate
// must miss. (1339 caught the same hole through an atlas-bake counter, which was the right
// fix for a design where the atlas re-baked; there is nothing to re-bake now, so the term that
// survives is the one that actually moved.)
//
// The generation term is the glyph sheet RESETTING — the one event that moves a cached cell's
// UV, which happens when the sheet fills and is repacked from empty. Held quads carry the old
// UVs and would draw letters as fragments of other letters.
//
// A [`crate::ui_script::VmMemo`] because a skip is not only a quad-conversion skip: it also skips
// `set_link_spans` and the minimap-slot / booth-pane refills, which are pushes INTO the VM. The
// VM lives for one login (decision 1290), so a fresh VM must never be gated on what the previous
// one extracted — its first frame is always a real conversion.
mut prev: Local<crate::ui_script::VmMemo<GateInputs>>,
// This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`).
mut ui_cost: ResMut<super::UiFrameCost>,
// Whether the tick half ran this frame, and what it derived.
pass: Res<super::UiPassState>,
) {
let (capture, ui_cost_wanted) = run;
// The paint half stands down whenever the tick half did — no VM, or no window. Both of those
// arms already cleared the booth panes, and re-deriving the seam here would be inventing a
// frame the VM never had.
if !pass.live {
return;
}
let Some(mut script) = script else {
return;
};
let prev = prev.get(&script);
let Ok(window) = window.single() else {
return;
};
let (w, h) = (window.width(), window.height());
let (s, dpi) = (pass.seam, pass.dpi);
let (us_tick, us_resolve) = (pass.us_tick, pass.us_resolve);
let (solves_before, derives_before) = (Some(pass.solves_before), Some(pass.derives_before));
let printing = ui_cost_enabled();
let cost_on = printing || ui_cost_wanted.0;
let mut t_mark = cost_on.then(std::time::Instant::now);
// Marks phase boundaries under the meter: returns μs since the previous mark and re-arms.
let mut lap = move || -> u128 {
if !cost_on {
return 0;
}
t_mark
.replace(std::time::Instant::now())
.map_or(0, |t| t.elapsed().as_micros())
};
// ── The SECOND measure + resolve, and the reason this half is its own system ─────────────
// Everything written into the VM since the tick half ran becomes real here — the nameplate
// anchors above all, which `vplates::drive_vplates` writes from a camera that did not exist
// when the tick half ran. Incremental: the ledger solves what is dirty, so a frame in which
// nothing moved pays a walk and no solve. A plate whose unit's NAME changed also needs its
// measure served here, which is why the round-trip comes along rather than the resolve alone.
let mut measured_any = false;
if let Some(atlas) = font_atlas.as_deref_mut() {
measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0);
}
{
let _span = bevy::log::info_span!("ui_script: resolve").entered();
script.resolve();
}
if measured_any {
if let Some(atlas) = font_atlas.as_deref_mut() {
if measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0) {
script.resolve();
}
}
}
// Folded into the tick half's own measure figure: one frame, one row.
let us_measure = pass.us_measure + lap();
// The glyph sheet's repack counter — the extract gate's term, read here because the gate is
// here (a repack moves every cached cell's UV, so held quads would draw letter fragments).
let generation = font_atlas.as_deref().map(|a| a.generation);
let extract_span = bevy::log::info_span!("ui_script: extract").entered();
let mut out = Vec::new();
let mut assets = world_assets;
@@ -1596,6 +1700,22 @@ fn convert_entry(
// 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 p == benilla_ui::script::nameplate::BORDER_TEXTURE {
// **Where the plate actually PAINTED**, on the same `vpl` tag the driver's
// own seat line rides (`vplates`'s jitter decomposition). The driver says
// where it put the plate; this says where the frame system drew it, in the
// same window px — so "is the paint this frame's or last frame's?" is a
// diff of two numbers instead of an argument about the schedule. It is the
// measurement decision 2168 was taken on, and the one that proves it
// stays fixed.
if benilla_assets::trace::enabled_for("vpl") {
benilla_assets::trace::line(
"vpl",
&format!(
"paint=({:.1},{:.1})..({:.1},{:.1})",
rect.min.x, rect.min.y, rect.max.x, rect.max.y
),
);
}
// 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
@@ -1975,8 +2095,8 @@ mod cursor_quad_tests {
}
/// The app-side half of the ScrollFrame clip plumb (decision 0112): does a [`UiQuad`] built from a
/// clipped [`QuadContent::Texture`] actually carry `clip` through `drive_script`? Drives the real
/// system in a minimal headless `App` (no `DefaultPlugins` — just the resources `drive_script`
/// clipped [`QuadContent::Texture`] actually carry `clip` through the UI pass? Drives the real
/// systems in a minimal headless `App` (no `DefaultPlugins` — just the resources the pass
/// reads), rather than re-deriving the y-up→y-down flip by hand: that flip is exactly the seam this
/// test exists to catch a regression in.
#[cfg(test)]
@@ -1987,10 +2107,10 @@ mod clip_plumb_tests {
use benilla_ui::script::UiScript;
use super::{drive_script, UiQuad, UiQuads};
use super::{paint_script, tick_script, UiQuad, UiQuads};
use crate::portrait::PortraitImages;
/// A headless app with exactly the resources/entities `drive_script` reads: a `NonSend` VM
/// A headless app with exactly the resources/entities the UI pass reads: a `NonSend` VM
/// carrying a ScrollFrame + scrolled-out child + a colored (pathless) Texture region marker, a
/// 1024×768 primary window (the 768-virtual design height — s = 1, so the WoW-space rects are
/// known by hand; decision 0582), and every other resource
@@ -2017,6 +2137,8 @@ mod clip_plumb_tests {
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
// The pass's own handover (2168) — the tick half writes it, the paint half reads it.
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
@@ -2037,7 +2159,9 @@ mod clip_plumb_tests {
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
// The pass is two systems since 2168, and a test that drives it drives BOTH — the quads are
// the second half's.
app.add_systems(Update, (tick_script, paint_script).chain());
app
}
@@ -2104,6 +2228,8 @@ mod clip_plumb_tests {
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
// The pass's own handover (2168) — the tick half writes it, the paint half reads it.
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
@@ -2122,7 +2248,9 @@ mod clip_plumb_tests {
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
// The pass is two systems since 2168, and a test that drives it drives BOTH — the quads are
// the second half's.
app.add_systems(Update, (tick_script, paint_script).chain());
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
@@ -2286,7 +2414,7 @@ mod extract_gate_tests {
use benilla_ui::script::UiScript;
use super::{drive_script, UiQuads};
use super::{paint_script, tick_script, UiQuads};
use crate::portrait::PortraitImages;
fn app_with_marker() -> App {
@@ -2311,6 +2439,8 @@ mod extract_gate_tests {
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
// The pass's own handover (2168) — the tick half writes it, the paint half reads it.
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
@@ -2329,7 +2459,9 @@ mod extract_gate_tests {
},
PrimaryWindow,
));
app.add_systems(Update, drive_script);
// The pass is two systems since 2168, and a test that drives it drives BOTH — the quads are
// the second half's.
app.add_systems(Update, (tick_script, paint_script).chain());
app
}
@@ -18,7 +18,7 @@ static TEXT_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1"));
/// The `QuadContent::Text` payload minus the text itself — the region's resolved style, passed
/// verbatim from the destructure at the [`super::drive_script`] call site.
/// verbatim from the destructure at the [`super::paint_script`] call site.
pub(super) struct TextStyle {
pub color: Option<[f32; 4]>,
pub justify_h: JustifyH,
+2 -2
View File
@@ -1,5 +1,5 @@
//! The player-UI input pass: [`feed_ui_input`] hit-tests the cursor and dispatches mouse/keyboard
//! events into the UI engine (after [`super::extract::drive_script`] has resolved the frame's
//! events into the UI engine (after [`super::extract::tick_script`] has resolved the frame's
//! rects), plus the action-bar key map. The OS pasteboard itself lives in [`crate::textinput`].
//! Split out of [`super`] purely for size — the plugin wiring and the extraction pass live there
//! and in [`super::extract`] respectively.
@@ -59,7 +59,7 @@ impl PointerFeed<'_> {
}
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
/// [`super::extract::drive_script`] has resolved this frame's rects), firing
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
/// char/key dispatch, publishing [`PlayerUiHover`] (so the pointer arbiter yields world-pick/camera to
/// the UI) and [`UiKeyboardCapture`] (so gameplay/dev keyboard readers yield to a focused box).
@@ -418,7 +418,7 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// walk below loads may measure the text it just set — the era's own tab law is
// `label:GetStringWidth() + 40` at OnLoad, and the addon corpus writes the same pair — and a
// `GetStringWidth` with no measurer installed answers 0. Seated only from the per-frame pass
// (`extract::drive_script`, an `Update` system), a VM that is BORN and LOADED inside one
// (`extract::tick_script`, an `Update` system), a VM that is BORN and LOADED inside one
// exclusive `PreUpdate` slot never sees it: that is exactly `ReloadUI()`, which mints a fresh
// boot VM in `end_ui_session` and calls straight into here, so every `/reload` measured 0
// through its whole load edge and only converged a frame later off whatever poll the caller
@@ -458,7 +458,7 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
/// the interface moving** (decision 2023): the composite is the renderer's per-frame output in
/// the overlay lane, never a product of the memoized conversion — which is exactly what the
/// first shape got wrong, and why this test used to re-ping to "move the pane" before asking
/// for the quad. Drives the real `drive_script` in the headless harness the clip-plumb tests
/// for the quad. Drives the real UI pass in the headless harness the clip-plumb tests
/// use.
#[test]
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
@@ -529,6 +529,8 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
let mut app = App::new();
app.insert_non_send_resource(s);
app.init_resource::<UiQuads>();
// The pass's own handover (2168) — the tick half writes it, the paint half reads it.
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<crate::portrait::PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
@@ -554,7 +556,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
app.add_systems(
Update,
(
super::extract::drive_script,
(super::extract::tick_script, super::extract::paint_script).chain(),
crate::ui_models::compose_tiles,
)
.chain(),
@@ -89,7 +89,7 @@ fn caption(s: &UiScript, frame: &str) -> Option<String> {
.unwrap()
}
/// One tick of the app's real order (`drive_script`): OnUpdate, resolve, then the draw list.
/// One tick of the app's real order (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
+134 -6
View File
@@ -126,7 +126,7 @@ pub(crate) struct InspectMode {
pub(crate) enabled: bool,
}
/// One frame's UI-pass phase split, in μs — written by [`extract::drive_script`] under the same
/// One frame's UI-pass phase split, in μs — written by [`extract::tick_script`] and [`extract::paint_script`] under the same
/// marks the `[ui-cost]` line prints. **Owned by the producer** (decision 1174): the split is a
/// fact this pass publishes about itself, so it must exist whether or not the recorder that reads
/// it (`hover_log`) is compiled in. Its consumers are instruments; its writer is not.
@@ -218,6 +218,57 @@ pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
#[derive(Resource, Default)]
pub(crate) struct CursorPayloadHeld(pub(crate) bool);
/// What [`extract::tick_script`] hands [`extract::paint_script`] — the UI pass is two systems, and
/// this is the frame they agree on (decision 2168).
///
/// `live` is the load-bearing field: the tick half returns early with no VM or no window, and the
/// paint half must then do nothing at all rather than re-derive a seam for a frame the VM never
/// had. Everything else is a value the two halves must not compute twice, because computing it
/// twice is how they would come to disagree.
#[derive(Resource, Default)]
pub(crate) struct UiPassState {
/// The tick half ran to completion this frame.
pub(crate) live: bool,
/// The 768-virtual seam scale (decision 0582/0584) this frame was ticked under.
pub(crate) seam: f32,
/// The window's device scale factor.
pub(crate) dpi: f32,
/// The meter's first three phases, so one `[ui-cost]` row still describes one frame.
pub(crate) us_tick: u128,
pub(crate) us_resolve: u128,
pub(crate) us_measure: u128,
/// The layout counters as they stood before the tick, so the row's `solves`/`derives` cover
/// BOTH halves' resolves.
pub(crate) solves_before: u64,
pub(crate) derives_before: u64,
}
/// **The pointer is over UI CHROME** — [`PointerOverUi`] minus the nameplates.
///
/// A plate is real mouse-enabled UI (2148/2159): it takes the hover, fires an addon's `OnEnter`,
/// and a click on it selects. It is not a *panel*, though, and the two consumers that mean "the
/// player is working in the interface, keep the world's hands off the mouse" have to say so:
///
/// - the camera's world-mouse latch — a right-drag that starts on a plate must still turn the view
/// (decision 2159 §2, which argued it from the reference's own `0x60f830`);
/// - the WHEEL — scrolling with the cursor on a plate must still zoom.
///
/// Both were regressions of the day a plate became a widget, and both are the same mistake, so
/// they are one named concept rather than two `&& plate_hover.is_none()`s.
///
/// **This is a narrowing of a coarse flag, not a fidelity claim.** The reference has no
/// "pointer over UI" boolean at all: it walks the strata for a frame that handles the message, and
/// a notch over any frame with no `OnMouseWheel` falls through to `CGWorldFrame` — an action button
/// included. Ours is one bit for the whole interface, and the plate is where that bit is visibly
/// wrong. Widening it to the reference's per-frame dispatch is its own arc.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUiPanel(pub(crate) bool);
/// The player-UI PAINT pass — the quad walk ([`extract::paint_script`]), ordered after the world's
/// camera update so that world-anchored widgets are drawn from this frame's camera (decision 2168).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiPaint;
/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so
/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -226,7 +277,7 @@ pub(crate) struct UiInput;
/// The frame's atomic (`Instant`, `GetTime`) clock pair — the ONE lawful base for mapping a
/// store-side `Instant` onto the VM's `GetTime` clock (`CooldownInfo::ui_triple` and kin).
///
/// Written at the single `script.tick` site ([`extract::drive_script`]): `ui_now` is the value the
/// Written at the single `script.tick` site ([`extract::tick_script`]): `ui_now` is the value the
/// VM clock just advanced to, `anchor` is `Time<Real>`'s own `last_update()` — the exact instant
/// whose frame-to-frame differences ARE the deltas the VM clock accumulates. Because both legs
/// advance in lockstep by construction, a conversion `ui_now - (anchor - start)` yields the SAME
@@ -346,7 +397,7 @@ impl Plugin for UiScriptPlugin {
app.insert_resource(UiScaleCvar(default_ui_scale()))
// The UI pass publishes its per-frame phase split here every frame the cost meter or
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs `drive_script` (the extract tests) has it.
// runs the UI pass (the extract tests) has it.
.init_resource::<UiFrameCost>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
@@ -404,15 +455,17 @@ impl Plugin for UiScriptPlugin {
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// `tick_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
// pass is in-world only (decision 0193): the character-select glue screen owns the
// pointer + keyboard there (its exit edge resets the latches this pass normally drives).
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
Update,
(
extract::drive_script,
extract::tick_script,
input::feed_ui_input.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain()
@@ -423,6 +476,19 @@ impl Plugin for UiScriptPlugin {
.before(crate::bindings::BindingSet)
.before(WorldStage::Input),
)
// **The paint half, after the camera** (decision 2168): the quad walk runs once the
// world's own update has happened, so a widget anchored to the world — the nameplates,
// which since 2148 are real `WorldFrame` children seated by `vplates::drive_vplates`
// out of THIS frame's camera — is drawn where it belongs instead of a frame behind.
// Bounded on both sides: after the plate driver that writes those anchors, and before
// the append lane, whose minimap producer fills the widget slot this pass parks.
.add_systems(
Update,
extract::paint_script
.in_set(UiPaint)
.after(crate::vplates::VPlateSet)
.before(crate::ui_pass::UiQuadAppend),
)
// Combine the two pointer contributions (dev overlay + player UI) into the single
// `PointerOverUi` source of truth, after the hover is known and before gameplay reads it.
.add_systems(
@@ -462,7 +528,9 @@ fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
plate_hover: Res<crate::vplates::PlateHover>,
mut over: ResMut<PointerOverUi>,
mut panel: ResMut<PointerOverUiPanel>,
) {
// A cinematic used to need a third term here, and no longer does — the deletion is the point
// of decision 1734. `CinematicFrame` is `setAllPoints` + `enableMouse="true"`, so while it is
@@ -470,6 +538,10 @@ fn arbitrate_pointer_over_ui(
// every other frame out of it. The hit test now arrives at that on its own, so
// [`PlayerUiHover`] carries it and the special case is gone.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
// A nameplate is mouse-enabled UI and it is not CHROME — see [`PointerOverUiPanel`]. The plate
// hover is last frame's (the plate driver runs after the camera), which is the same vintage the
// camera's own plate exception has always used.
panel.0 = over.0 && plate_hover.0.is_none();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
@@ -497,7 +569,7 @@ pub(crate) use lifecycle::{
/// that nobody can take a value off. The pole is a one-second time constant — fast enough that a
/// real stall is visible within a frame or two, slow enough to read.
///
/// `Time<Real>` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and
/// `Time<Real>` deliberately, the same choice `tick_script` documents: this is a *frame rate*, and
/// a virtual clock that clamps its delta would report a rate the machine is not achieving.
fn feed_framerate(
script: Option<NonSendMut<UiScript>>,
@@ -1172,3 +1244,59 @@ mod seam_scale_tests {
assert_eq!(seam_scale(0.0, 0.9), 1.0);
}
}
#[cfg(test)]
mod pointer_arbiter_tests {
use super::*;
/// A world it can run the arbiter in: the three inputs and the two outputs, nothing else.
fn app() -> App {
let mut app = App::new();
app.init_resource::<PlayerUiHover>()
.init_resource::<crate::vplates::PlateHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.add_systems(Update, arbitrate_pointer_over_ui);
app
}
/// **A plate is UI, and it is not chrome.** The camera's world-mouse latch and the wheel's zoom
/// binding both read the second bit; both were dead over a plate until they did (2168).
#[test]
fn a_hovered_plate_is_ui_but_not_chrome() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
assert!(
app.world().resource::<PointerOverUi>().0,
"the plate takes the pointer"
);
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"…and the camera and the wheel look straight through it"
);
}
/// An ordinary frame is both, which is the whole point of keeping two bits rather than one.
#[test]
fn a_hovered_panel_is_both() {
let mut app = app();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.update();
assert!(app.world().resource::<PointerOverUi>().0);
assert!(app.world().resource::<PointerOverUiPanel>().0);
}
/// And empty world under the pointer is neither.
#[test]
fn no_hover_is_neither() {
let mut app = app();
app.update();
assert!(!app.world().resource::<PointerOverUi>().0);
assert!(!app.world().resource::<PointerOverUiPanel>().0);
}
}
@@ -4402,6 +4402,20 @@ 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",
),
(
"cameraWaterCollision",
"REVERTED 2026-09-10, and the row is back because the feature is not. Two consumers were \
transcribed off `pivot-height-glide.md` §5: (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 \
arms a 5° pitch through the `+0xf4` channel. Built as (1)=the pivot corridor alone, \
on the reading that the arm stays liquid-blind. **That corridor is discontinuous**: at \
`excess = 2/9` the framing pivot drops 1.04 yd in one frame and pops back 0.29 yd at \
`5/9`, so entering or leaving water at head depth slams the camera — which is what \
the director saw. Whether the discontinuity is the reference's or a mis-transcription is \
a live §5 (2165). Registered \"1\", so benilla is the divergence while this row \
stands",
),
// **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:
@@ -73,14 +73,14 @@ fn settled_default_ui() -> UiScript {
s
}
/// One frame in the app's own order (`extract::drive_script`): measure FIRST, then resolve.
/// One frame in the app's own order (`extract::tick_script`): measure FIRST, then resolve.
fn app_frame(s: &mut UiScript) {
answer_measures(s);
s.resolve();
}
/// A frame that also **ticks the VM**, i.e. runs the shipped UI's own `OnUpdate` handlers, in
/// `drive_script`'s real order (tick → measure → resolve).
/// `tick_script`'s real order (tick → measure → resolve).
///
/// [`app_frame`] deliberately models only the measure/resolve half, and the tooltip benches stand
/// in for the handler by calling it themselves. That is fine when the test IS the driver — but a
@@ -174,7 +174,7 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
assert_eq!(
solves, 10,
"10 content changes must cost 10 solves — one each. Two per change means the measure \
round-trip is running AFTER the resolve again (extract::drive_script's order)."
round-trip is running AFTER the resolve again (extract::tick_script's order)."
);
// …and none of those ten may DERIVE the graph (decision 1388). This is the second shape of
// the same law `a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui`
+5 -4
View File
@@ -186,10 +186,11 @@ fn upload_glyph_cells(
/// `WOW_GLYPH_CACHE=1`: one line a second of what the cache holds.
///
/// It exists because `super::pack::MAX_PAGES` and `super::pack::PAGE_SIZE` are the two numbers
/// this design can get wrong quietly — too small and a session resets on a loading screen, too
/// large and we hold VRAM nobody reads. Both are cheap to re-choose *from a measurement* and
/// expensive to re-choose from an argument, so the measurement ships with them.
/// It exists because `super::pack::SHEET_SIZE` is the number this design can get wrong quietly —
/// too small and a session resets on a loading screen, too large and we hold VRAM nobody reads. It
/// is cheap to re-choose *from a measurement* and expensive to re-choose from an argument, so the
/// measurement ships with it. (The plural it used to name — `MAX_PAGES`/`PAGE_SIZE` — went with
/// the multi-page design 1342 replaced with one sheet.)
fn report_cache(atlas: &UiFontAtlas) {
use std::sync::atomic::{AtomicU64, Ordering};
static LAST: AtomicU64 = AtomicU64::new(0);
+1 -1
View File
@@ -166,7 +166,7 @@ pub(crate) struct LinkSpan {
}
/// Lays out `text` within `rect` (screen px, **y-down** — the same space
/// [`crate::ui_script::extract::drive_script`] already flips frame rects into) and returns one
/// [`crate::ui_script::extract::paint_script`] already flips frame rects into) and returns one
/// [`UiQuad`] per non-blank glyph, textured with the page its cell was packed into. `z_key` is
/// shared by every glyph — the owning `FontString` region's own [`crate::ui_pass`]-order key
/// (regions already sort after their frame and by draw layer/decl, so reusing it keeps text in the
+168 -14
View File
@@ -68,8 +68,14 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
/// measurement rather than from an argument.
pub(super) const SHEET_SIZE: u32 = 2048;
/// Padding between neighbouring cells, in texels — one row/column left untouched, so a bilinear tap
/// at a cell edge cannot reach into its neighbour.
/// Padding between neighbouring cells, in texels — the room each cell's own transparent FRAME
/// lives in ([`Sheet::alloc`]), so a bilinear tap at a cell edge lands on that cell's colour at
/// alpha 0 and never on a neighbour.
///
/// One texel is exactly enough and no more: a quad's outermost pixel samples at most half a texel
/// outside its cell (the sample sits `0.5/magnification` texels inside the edge, so its bilinear
/// tap reaches `0.5 − 0.5/M < 0.5` texels past it, at any magnification), so the frame is the
/// farthest any tap can see.
const PAD: u32 = 1;
/// A cell's texel payload, in the two shapes the rasterizer produces.
@@ -108,6 +114,52 @@ pub(super) struct Sheet {
uploads: Vec<CellUpload>,
}
/// Wrap a cell's texels in the **transparent frame it owns**: one texel on every side, carrying the
/// cell's own edge colour at **alpha 0**, uploaded with the cell and clipped at the sheet's bounds.
///
/// A magnified glyph quad samples up to half a texel past its own cell (see [`PAD`]) — so what
/// sits in the gap is *drawn*, at up to half strength, as a one-pixel rectangle around every
/// letter. Leaving the gap merely *unwritten* is not enough, because [`Sheet::reset`] re-packs a
/// live sheet without erasing it: after the first reset a new cell's gap lands on the previous
/// packing's ink, and every magnified name wears a faint box of somebody else's letters (the
/// director's `<GM>Onewarrior` report, 2026-09-10 — reproduced by poisoning the sheet, which
/// draws the identical box). Owning the frame makes the sheet's history irrelevant: whatever a
/// tap reaches belongs to this cell and is transparent.
///
/// **Alpha 0 with the EDGE colour, not transparent black.** These are straight-alpha texels, so a
/// filtered tap mixes RGB and alpha independently; ramping to transparent *black* would pull the
/// glyph's outer edge toward black (the classic dark fringe). Extruding the edge colour leaves the
/// hue alone and ramps only coverage, which is what a soft glyph edge is.
///
/// Clipped rather than shifted at the sheet's edge: a cell at `x = 0` has no column −1, and none is
/// needed — `ClampToEdge` answers a `u < 0` tap with the cell's own first column.
///
/// Two neighbouring cells share the one gap texel between them, so the later one's frame overwrites
/// the earlier one's. Both are alpha 0, so only the RGB a fully transparent texel carries differs —
/// a hue detail half a texel wide, and never ink.
fn frame(x: u32, y: u32, w: u32, h: u32, rgba: &[u8]) -> (u32, u32, u32, u32, Vec<u8>) {
let (fx, fy) = (x.saturating_sub(1), y.saturating_sub(1));
let (fw, fh) = (
(x + w + 1).min(SHEET_SIZE) - fx,
(y + h + 1).min(SHEET_SIZE) - fy,
);
let mut out = vec![0u8; (fw * fh * 4) as usize];
for row in 0..fh {
let ty = fy + row;
let sy = ty.clamp(y, y + h - 1) - y;
for col in 0..fw {
let tx = fx + col;
let sx = tx.clamp(x, x + w - 1) - x;
let si = ((sy * w + sx) * 4) as usize;
let di = ((row * fw + col) * 4) as usize;
out[di..di + 3].copy_from_slice(&rgba[si..si + 3]);
let inside = tx >= x && tx < x + w && ty >= y && ty < y + h;
out[di + 3] = if inside { rgba[si + 3] } else { 0 };
}
}
(fx, fy, fw, fh, out)
}
impl Sheet {
/// Reserve the handle. Costs nothing but an id: no texels are allocated and no `Image` asset
/// exists until a cell actually lands.
@@ -128,9 +180,14 @@ impl Sheet {
self.handle.clone()
}
/// Place one cell and queue its texels. `None` means the sheet is full (or the cell could never
/// fit) — the caller's cue to ask for a reset, and to draw nothing for this glyph meanwhile,
/// which is a missing letter for at most one frame.
/// Place one cell and queue its texels — **with the transparent frame it owns** ([`frame`]), so
/// what a magnified quad samples just past the cell is this cell's own colour at alpha 0 and
/// never the sheet's history. The returned UV is the INNER rect: the frame is sampled, never
/// addressed.
///
/// `None` means the sheet is full (or the cell could never fit) — the caller's cue to ask for a
/// reset, and to draw nothing for this glyph meanwhile, which is a missing letter for at most
/// one frame.
pub(super) fn alloc(&mut self, w: u32, h: u32, cell: &Cell<'_>) -> Option<Rect> {
if w == 0 || h == 0 || w > SHEET_SIZE || h > SHEET_SIZE {
return None; // a zero-ink glyph (a space), or a cell bigger than the whole sheet
@@ -167,13 +224,14 @@ impl Sheet {
self.created = true;
self.announce = true;
}
let (fx, fy, fw, fh, framed) = frame(x, y, w, h, &rgba);
self.uploads.push(CellUpload {
image: self.handle.id(),
x,
y,
w,
h,
rgba,
x: fx,
y: fy,
w: fw,
h: fh,
rgba: framed,
});
// Normalized against the FIXED side, never against how full the sheet is — the whole reason
// a cached UV stays valid for the life of the texture (module doc).
@@ -198,6 +256,11 @@ impl Sheet {
/// memory of what is where resets, so a quad emitted before the reset keeps drawing correctly
/// until its own re-emit replaces it. The caller owns telling the world; see the generation
/// counter on [`super::engine::TextEngine`].
///
/// The surviving texels mean the next packing lands **on top of the old one**, and its cells'
/// gaps land on the old packing's ink. That is why every cell writes its own frame ([`frame`])
/// rather than trusting the gap to be blank: a blank gap is only ever true of a sheet that has
/// never reset.
pub(super) fn reset(&mut self) {
self.cursor_x = 0;
self.cursor_y = 0;
@@ -247,6 +310,21 @@ mod tests {
vec![255u8; (w * h) as usize]
}
/// One texel of an upload, addressed in SHEET coordinates — uploads carry the cell plus the
/// frame around it, so a test that wants "the cell's texel (0,0)" has to say where that is.
fn texel(u: &CellUpload, x: u32, y: u32) -> [u8; 4] {
assert!(
x >= u.x && x < u.x + u.w && y >= u.y && y < u.y + u.h,
"({x},{y}) is outside the upload [{}..{}, {}..{}]",
u.x,
u.x + u.w,
u.y,
u.y + u.h
);
let i = (((y - u.y) * u.w + (x - u.x)) * 4) as usize;
[u.rgba[i], u.rgba[i + 1], u.rgba[i + 2], u.rgba[i + 3]]
}
/// A cell's UV normalizes against the fixed side, so it is the same rect no matter what lands
/// afterwards. This is the property the nameplate meshes bake into the GPU, and the one whose
/// absence made a re-bake draw letters as fragments of other letters (1339).
@@ -260,10 +338,12 @@ mod tests {
for _ in 0..2000 {
s.alloc(8, 11, &Cell::Coverage(&c)).expect("room");
}
// Nothing repacked: the first cell's texels are still where its UV says they are.
// Nothing repacked: the first cell's texels are still where its UV says they are. Its
// upload starts at the sheet's own corner — the frame is clipped there, having nothing to
// guard (`ClampToEdge` answers a negative tap with the cell's own edge).
let (_, uploads) = s.take_pending();
assert_eq!((uploads[0].x, uploads[0].y), (0, 0));
assert_eq!((uploads[0].w, uploads[0].h), (8, 11));
assert_eq!((uploads[0].w, uploads[0].h), (9, 12));
}
/// Coverage becomes white × alpha; a composited cell goes up verbatim. The upload IS the
@@ -273,13 +353,87 @@ mod tests {
let mut s = sheet();
s.alloc(2, 1, &Cell::Coverage(&[0, 128])).expect("room");
let (_, uploads) = s.take_pending();
assert_eq!(uploads[0].rgba, vec![255, 255, 255, 0, 255, 255, 255, 128]);
assert_eq!(texel(&uploads[0], 0, 0), [255, 255, 255, 0]);
assert_eq!(texel(&uploads[0], 1, 0), [255, 255, 255, 128]);
let mut s = sheet();
let ring = vec![0u8, 0, 0, 153, 200, 200, 200, 255];
s.alloc(2, 1, &Cell::Rgba(&ring)).expect("room");
let (_, uploads) = s.take_pending();
assert_eq!(uploads[0].rgba, ring, "a composited cell is not re-tinted");
assert_eq!(texel(&uploads[0], 0, 0), [0, 0, 0, 153]);
assert_eq!(
texel(&uploads[0], 1, 0),
[200, 200, 200, 255],
"a composited cell is not re-tinted"
);
}
/// **The artifact this module exists to prevent.** A magnified glyph quad samples up to half a
/// texel past its own cell, so whatever sits in the gap is drawn as a one-pixel box around the
/// letter — and the gap is NOT reliably blank, because [`Sheet::reset`] re-packs a live sheet
/// without erasing it. So the cell writes the gap itself: its own edge colour at alpha 0, on
/// every side, in the same upload.
#[test]
fn a_cell_owns_a_transparent_frame_on_every_side() {
let mut s = sheet();
// Off both sheet edges, where all four sides of the frame are really written: fill the
// first shelf so the next cell wraps to a shelf of its own, then put one cell ahead of the
// subject so it is not against the left edge either.
let wide = SHEET_SIZE - 4;
s.alloc(wide, 3, &Cell::Coverage(&cov(wide, 3)))
.expect("shelf 0");
s.alloc(4, 3, &Cell::Coverage(&cov(4, 3))).expect("shelf 1");
// A cell whose edge texels are opaque, so "the frame is transparent" is a real claim.
let cell = [9u8, 8, 7, 255].repeat(6); // 3x2, uniform
let uv = s.alloc(3, 2, &Cell::Rgba(&cell)).expect("room");
let (_, uploads) = s.take_pending();
let u = uploads.last().expect("the cell went up");
// The UV names the INNER rect; the upload covers it plus one texel on every side.
let side = SHEET_SIZE as f32;
let (x0, y0) = ((uv.min.x * side) as u32, (uv.min.y * side) as u32);
assert_eq!(
(u.x, u.y),
(x0 - 1, y0 - 1),
"the frame is uploaded with the cell"
);
assert_eq!((u.w, u.h), (3 + 2, 2 + 2));
for ty in u.y..u.y + u.h {
for tx in u.x..u.x + u.w {
let inside = tx >= x0 && tx < x0 + 3 && ty >= y0 && ty < y0 + 2;
let t = texel(u, tx, ty);
assert_eq!(
&t[..3],
&[9, 8, 7],
"the frame extrudes the cell's own colour"
);
assert_eq!(
t[3],
if inside { 255 } else { 0 },
"({tx},{ty}) alpha: the cell is itself, the frame is transparent"
);
}
}
}
/// The frame writes into the padding, never into the neighbour whose ink shares that shelf —
/// which is what makes one texel of [`PAD`] enough to hold two cells' frames.
#[test]
fn a_frame_never_lands_on_a_neighbours_ink() {
let mut s = sheet();
let c = cov(6, 5);
let a = s.alloc(6, 5, &Cell::Coverage(&c)).expect("room");
let b = s.alloc(6, 5, &Cell::Coverage(&c)).expect("room");
let (_, uploads) = s.take_pending();
let side = SHEET_SIZE as f32;
let a_x1 = (a.max.x * side) as u32; // one past A's last ink column
let b_x0 = (b.min.x * side) as u32;
assert_eq!(b_x0, a_x1 + PAD, "one padding column between the two cells");
assert_eq!(
uploads[1].x, a_x1,
"B's frame starts in the padding, not on A's last ink column"
);
}
/// Shelves wrap downward when a cell will not fit beside its neighbour, and the sheet refuses
+28 -20
View File
@@ -803,19 +803,23 @@ fn drive_vplates(
.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(
// The skull's 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).
//
// **A unit with no level yet is not a boss.** The two facts travel separately, because
// folding them into one `Option` made a missing `UNIT_FIELD_LEVEL` read as a skull —
// the old painter kept the whole block inside `if let Some(level)`, so no level meant
// no number AND no skull, and that is the behaviour restored here.
level: store.and_then(|s| s.0.unit_level()),
skull: store.and_then(|s| s.0.unit_level()).is_some_and(|level| {
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)
) == 3
|| (rank <= 1 && level >= my_level + 10 && !unit_is_grey(my_level, level))
}),
level_colour: {
let c = store
@@ -848,6 +852,10 @@ fn drive_vplates(
raid_size: gx(RAID_ICON_SIZE) / seam,
name_height: text_px(NAME_H, basis) / seam,
level_height: text_px(LEVEL_H, basis) / seam,
// A constant ONE LOGICAL PIXEL of drop shadow, in the widget layer's units — the
// director's 2026-07-07 tuning, which the shared text arm would otherwise scale by
// this same seam and draw 2 px thick from a 1152-tall window up.
shadow_offset: 1.0 / seam,
},
&states,
);
@@ -875,15 +883,15 @@ impl Plugin for VPlatesPlugin {
// `.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.
// **And the paint is this frame's too, since decision 2168.** The UI pass is
// two systems: the tick/resolve half stays ahead of `WorldStage::Input`
// (the hit test feeds `PointerOverUi`, which the camera reads), and the QUAD
// half — `ui_script::extract::paint_script` — runs after this driver. 2148
// shipped with the whole pass ahead of the camera, so the anchors written here
// were drawn by the NEXT frame's extract: the seat reached the paint 16 ms
// late, every frame, which is the director's "way more jittered when the
// creature is moving" (measured both ways on the `vpl` trace, 2026-09-10 —
// median driver→paint gap 16.0 ms before, 0.0 ms after).
drive_vplates.after(TargetUpdate),
)
.chain()
+144 -1
View File
@@ -414,9 +414,152 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
install_nameplate_verbs(lua)
install_nameplate_verbs(lua)?;
install_world_detail_verbs(lua)
}
/// The `WorldDetail` slider's **stop**, benilla's spelling — the panel position `0`/`1`/`2`, held as
/// a CVar because 1.12 has no such CVar and our own Graphics page needs somewhere to keep it (the
/// `autoLootDefault` posture; the app's table carries the reasoning and the `Same` row).
///
/// Defined here, in the crate that publishes the two verbs that read and write it, so the binding
/// and the name cannot drift apart — as for [`CVAR_NAMEPLATE_ENEMIES`]. The app welds both with its
/// own test.
pub const CVAR_WORLD_DETAIL: &str = "WorldDetail";
/// The reference's own Environment Detail CVar — cells visited per chunk by the detail-doodad
/// scatter. [`WORLD_DETAIL_STOPS`] is what the slider writes into it.
pub const CVAR_FRILL_DENSITY: &str = "frillDensity";
/// **`SetWorldDetail`'s preset table, verbatim** — the three dwords at `0x804518`, `{0x10, 0x20,
/// 0x30}`. Read out of `WoW.exe` rather than transcribed from a note.
pub const WORLD_DETAIL_STOPS: [u32; 3] = [16, 32, 48];
/// **The Environment Detail pair** — `SetWorldDetail 0x488dd0` and `GetWorldDetail 0x488d70`, the
/// two engine bindings 1.12's own options panel drives that slider through (`OptionsFrame.lua`'s
/// row 3 is `func = "WorldDetail"`, and `OptionsFrame_Save`/`_Load` prefer `getglobal("Set"..func)`
/// / `getglobal("Get"..func)` over `SetCVar`/`GetCVar`). Decision 2163.
///
/// **The setter, carved end to end** (own decode, agreeing with wow-re
/// `cvar/scratch/registered-defaults-census.md` §8):
///
/// ```text
/// 488ddf call 0x6f34d0 ; lua_isnumber(L,1)? else error 0x8423e8
/// 488e05 call 0x6f3620 ; lua_tonumber(L,1)
/// 488e0a call 0x40a2b0 ; -> int, RC forced to CHOP (`or ah,0x0c`): TRUNCATE toward zero
/// 488e11 test esi,esi; jl 0x488e9b ; n < 0 -> error 0x8423b8
/// 488e19 cmp esi,3; jge 0x488e9b ; n >= 3 -> error 0x8423b8
/// 488e1e mov eax,[esi*4 + 0x804518] ; {16,32,48} -> "%d" -> CVar::Set "frillDensity"
/// 488e56 fld dword [esi*4+0x804524] ; {0.07,0.04,0.01} -> "%f" -> CVar::Set "smallCull"
/// ```
///
/// The two error strings are `0x8423e8` `"Usage: SetWorldDetail(value)"` and `0x8423b8` `"value
/// must be in the range 0, 2"` — both read out of the image, lowercase `v` and all. Every path
/// returns **zero** Lua values.
///
/// Three shapes worth stating because the obvious reading is wrong on each:
///
/// - **The truncation is toward zero, not a floor.** `0x40a2b0` saves the FPU control word, ORs
/// `0x0c` into the high byte (rounding-control = chop) and `fistp`s. So `SetWorldDetail(2.9)`
/// is stop 2 and `SetWorldDetail(-0.5)` is stop **0** — a negative that truncates to zero is
/// accepted, and only `<= -1` raises.
/// - **`lua_isnumber` accepts a numeric string**, so `SetWorldDetail("2")` works there and here.
/// - **The getter is not the setter's inverse.** `0x488d70` reads only `smallCull`, seeds its
/// result at **2**, and runs `for i in 0..3 { if v <= tbl[i] { result = i } }` over the
/// *descending* `{0.07, 0.04, 0.01}` with **no break** — so the last match wins, and any
/// `smallCull` above `0.07` (legal to 2.0) saturates to 2, i.e. the least detail reads back as
/// the most. A shipped defect, and one of two: `OptionsFrame_SetDefaults` tests that same
/// descending table with ascending `elseif`s, so Restore Defaults always parks the slider at 0.
///
/// **Where benilla diverges, and why it is not the smallCull ladder.** `SmallCull` is a dead knob
/// in the reference — `[0x868620]` has one writer and no reader image-wide (wow-re
/// `cvar/scratch/graphics-cost-cvar-census.md` §8) — so its *only* consumer there is this getter,
/// which makes it storage for a stop the client keeps nowhere else. benilla keeps the stop:
/// [`CVAR_WORLD_DETAIL`] is it, and `frillDensity` is the same knob in the reference's unit. So the
/// getter reads the stop directly and `SmallCull` is not registered, because registering a CVar
/// whose only purpose is to hold a number we already hold is the silent pretence 1203 forbids.
///
/// The observable consequence is **nil at boot and nil for every consumer we know**: a fresh
/// reference client has `frillDensity 16` with `smallCull 0.04` — stop 0's frill with stop 1's cull
/// — so its getter answers **1**, and benilla's `WorldDetail` registers at `"1"` for exactly that
/// reason. The one input that separates them is a bare `frillDensity` write with no stop write,
/// which there leaves the getter on the stale stop and here moves it; pfUI's `hdgraphic` is the
/// only known caller and its own replacement short-circuits above 48, so it never reaches the
/// difference.
fn install_world_detail_verbs(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"SetWorldDetail",
lua.create_function(|lua, value: Value| {
// `lua_isnumber`: a number, or a string Lua can convert to one. Anything else is the
// usage error — NOT a silent no-op.
let n = match &value {
Value::Integer(i) => *i as f64,
Value::Number(n) => *n,
Value::String(s) => {
match s.to_str().ok().and_then(|s| s.trim().parse::<f64>().ok()) {
Some(n) => n,
None => return Err(mlua::Error::runtime(USAGE_SET_WORLD_DETAIL)),
}
}
_ => return Err(mlua::Error::runtime(USAGE_SET_WORLD_DETAIL)),
};
// Truncate toward zero (`0x40a2b0`'s chop), then the reference's own two bounds. NaN
// has no truncation — the reference's `fistp` yields the integer indefinite, which is
// negative and raises, so raising here is the same answer by the same door.
if n.is_nan() {
return Err(mlua::Error::runtime(RANGE_SET_WORLD_DETAIL));
}
let stop = n.trunc();
if !(0.0..3.0).contains(&stop) {
return Err(mlua::Error::runtime(RANGE_SET_WORLD_DETAIL));
}
let stop = stop as usize;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// TWO CVars per stop, as `0x488dd0` writes two: there it is `frillDensity` + the dead
// `smallCull`; here it is `frillDensity` + the stop the panel keeps. Both land on one
// clutter knob in the host, so applying both is idempotent — and writing only one
// would leave the other stale for a `GetWorldDetail` in the same Lua tick, since the
// host does not drain the change queue until the end of the frame.
write_cvar(
&mut model,
CVAR_FRILL_DENSITY,
WORLD_DETAIL_STOPS[stop].to_string(),
None,
);
write_cvar(&mut model, CVAR_WORLD_DETAIL, stop.to_string(), None);
// Zero return values, not nil (`eax = 0` at every `ret`).
Ok(mlua::MultiValue::new())
})?,
)?;
g.set(
"GetWorldDetail",
// `MultiValue` in: the reference reads argument 1 for the SETTER only; the getter never
// calls `lua_gettop`, so an argument is accepted and ignored — and pfUI's replacement is
// declared `function _G.GetWorldDetail(arg)`, so it is passed one in practice.
lua.create_function(|lua, _: mlua::MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let stop = model
.cvars
.get(&CVAR_WORLD_DETAIL.to_ascii_lowercase())
.and_then(|slot| slot.value.parse::<f64>().ok())
// The reference can only ever answer 0, 1 or 2 (its result is an index into a
// three-entry table), so an off-grid stop — which ours can hold, because a console
// `frillDensity 200` moves the same knob — reports the nearest one rather than a
// number no caller has a branch for.
.map_or(0, |v| v.round().clamp(0.0, 2.0) as i64);
Ok(stop)
})?,
)?;
Ok(())
}
/// `0x8423e8`, verbatim.
const USAGE_SET_WORLD_DETAIL: &str = "Usage: SetWorldDetail(value)";
/// `0x8423b8`, verbatim — lowercase `v`, and the odd comma is the reference's own.
const RANGE_SET_WORLD_DETAIL: &str = "value must be in the range 0, 2";
/// **The four nameplate verbs** — `ShowNameplates 0x489450`, `HideNameplates 0x489460`,
/// `ShowFriendNameplates 0x489470`, `HideFriendNameplates 0x489480` (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, §5 four-worker round).
+9
View File
@@ -331,6 +331,15 @@ impl UiScript {
if data_ref.is_some_and(|d| d.hidden) {
continue;
}
// THE NAMEPLATE GLOW IS SHOWN AND NOT DRAWN — the one region in the engine
// whose paint the director replaced with something else (decision 0184: the
// lit plate brightens its bar instead of wearing the additive rim). It has to
// stay a real, shown, ADD-blended `Nameplate-Glow` region because
// `glow:IsShown()` IS the mouseover signal every 1.12 nameplate addon reads —
// so the deviation lives here, at the paint, and nowhere in the model.
if data_ref.is_some_and(super::nameplate::is_unpainted_glow) {
continue;
}
let mut data = data_ref.cloned().unwrap_or_default();
// The single-hop draw multiply (`propagation.md`): the region's own alpha times
// its immediate owner's — never a product up the tree, because the owner's own
+4 -1
View File
@@ -200,7 +200,10 @@ pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorMerchantItem, CursorMoney, CursorPayload,
CursorPetAction, CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
pub use cvars::{
MultisampleFormat, CVAR_FRILL_DENSITY, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS,
CVAR_WORLD_DETAIL, WORLD_DETAIL_STOPS,
};
pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
+134 -26
View File
@@ -76,7 +76,7 @@ use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle, RegionKind}
use super::model::Model;
use super::UiScript;
use super::{FontShadow, JustifyV, TexCoords};
use super::{BlendMode, FontShadow, JustifyV, RegionData, 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.
@@ -123,6 +123,16 @@ pub struct PlateGeometry {
/// Font heights for the name and the level.
pub name_height: f32,
pub level_height: f32,
/// The plate text's drop-shadow step, in the same FrameXML units — **the driver divides the
/// seam out of it like everything else here**, because the director tuned it as a constant
/// ONE LOGICAL PIXEL (2026-07-07) and the shared text arm multiplies whatever it finds by the
/// seam on the way out.
///
/// Left as a bare `1.0` unit by the widget port, it became `round(windowH/768 × uiScale)`
/// drawn px — 2 px from a 1152-tall window up, 3 px at 2160 — which is exactly the "chunky"
/// the recorded ±0.001 gx law was tuned away from, and it made the shadow the one part of a
/// plate that was not uiScale-blind.
pub shadow_offset: f32,
}
/// One plate's per-frame state, as the app hands it over: everything that can move while a plate is
@@ -152,8 +162,14 @@ pub struct PlateState {
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.
/// The level number to show, or `None` when the unit has no `UNIT_FIELD_LEVEL` yet.
///
/// **`None` is not the skull** — that is [`Self::skull`], and conflating them is what made a
/// unit whose level had not arrived wear a world boss's skull. The old painter kept the whole
/// level/skull block inside `if let Some(level)`, so no level meant neither.
pub level: Option<u32>,
/// Show the SKULL in the level's seat instead of a number — the boss / far-out-of-range mark.
pub skull: bool,
/// 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`.
@@ -182,6 +198,17 @@ struct Plate {
fill: RegionHandle,
/// Last frame's state, so [`Plate::drive`] writes only what moved. `None` = retired.
last: Option<PlateState>,
/// **The geometry this plate's anchors were written under**, or `None` while it has never been
/// laid out — the fix for a plate that was born invisible.
///
/// [`Plate::lay_out`] used to be driven by one per-frame `relayout` flag ("did the window
/// move?"), which is true exactly once per window size. A plate the pool grew on any LATER
/// frame — a unit walking into range with no free slot — therefore kept `width: 0`, `height: 0`
/// and no region anchors, resolved to nothing, and drew nothing for the rest of the session.
/// The number of plates on screen was capped at however many were up on the frame V was first
/// pressed. Per-plate state instead of a per-frame flag makes the condition unfalsifiable: a
/// plate whose anchors are not the current geometry's gets them, whatever the reason.
laid_out: Option<PlateGeometry>,
}
impl Plate {
@@ -395,7 +422,7 @@ fn sync(
i
}
};
if relayout {
if model.nameplates.plates[slot].laid_out != Some(geometry) {
Plate::lay_out(model, slot, geometry);
}
Plate::drive(model, slot, world, state, &mut effects);
@@ -405,7 +432,7 @@ fn sync(
// 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() {
if model.nameplates.plates[i].laid_out != Some(geometry) {
Plate::lay_out(model, i, geometry);
}
}
@@ -437,12 +464,40 @@ impl Plate {
// `EnableMouse`/`IsMouseEnabled` are in the corpus's own call set, and pfUI's vanilla
// click-through block drives them.
let border = texture(model, frame, DrawLayer::Artwork, BORDER_TEXTURE);
let glow = texture(model, frame, DrawLayer::Highlight, GLOW_TEXTURE);
// The blend modes are the ctor's own, one `0x7703f0` call per texture (wow-re
// `nameplate-vkey.md` §8.1, byte-arbitrated): everything is BLEND(2) except the **glow,
// which is ADD(3)** (`push 3` at `0x7cb36a` → `0x7cb374`). Getting that one wrong is not a
// subtlety — see [`is_unpainted_glow`].
let border = texture(
model,
frame,
DrawLayer::Artwork,
BORDER_TEXTURE,
BlendMode::Blend,
);
let glow = texture(
model,
frame,
DrawLayer::Highlight,
GLOW_TEXTURE,
BlendMode::Add,
);
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);
let skull = texture(
model,
frame,
DrawLayer::Overlay,
SKULL_TEXTURE,
BlendMode::Blend,
);
let raid = texture(
model,
frame,
DrawLayer::Artwork,
RAID_ICON_TEXTURE,
BlendMode::Blend,
);
// ↑ 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.
@@ -453,7 +508,12 @@ impl Plate {
.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());
let fill_data = model.region_data.entry(fill).or_default();
fill_data.texture = Some(BAR_FILL_TEXTURE.to_string());
// Not overridden by the ctor — the `CSimpleTexture` ctor's own `[+0xd0] = 2` stands
// (`0x76fc40`; vkey §8.1). Written explicitly because the default is a FACT here, not an
// omission.
fill_data.blend = BlendMode::Blend;
if let Some(KindState::StatusBar(sb)) =
model.arena.frame_mut(bar).map(|f| &mut f.kind_state)
{
@@ -491,6 +551,7 @@ impl Plate {
bar,
fill,
last: None,
laid_out: None,
}
}
@@ -540,6 +601,13 @@ impl Plate {
name_data.font_height = Some(g.name_height);
// The level — ("CENTER", plate, "BOTTOMRIGHT", −x, +y), the other validated pair.
for rh in [name, level] {
model.region_data.entry(rh).or_default().font_shadow = Some(FontShadow {
offset: [g.shadow_offset, -g.shadow_offset],
color: [0.0, 0.0, 0.0, 1.0],
});
}
let level_data = model.region_data.entry(level).or_default();
level_data.anchors = vec![Anchor::new(
Point::Center,
@@ -571,6 +639,7 @@ impl Plate {
// 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();
model.nameplates.plates[i].laid_out = Some(g);
}
/// The per-frame write: position, health, texts, colours, shown flags, alpha — **and nothing
@@ -670,12 +739,16 @@ impl Plate {
}
// 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) => {
if last.as_ref().is_none_or(|l| {
l.level != state.level || l.skull != state.skull || l.level_colour != state.level_colour
}) {
match (state.level, state.skull) {
// The skull wins the seat, and it is the SKULL FLAG that puts it there.
(_, true) => {
model.region_data.entry(level).or_default().hidden = true;
model.region_data.entry(skull).or_default().hidden = false;
}
(Some(n), false) => {
let c = state.level_colour;
let data = model.region_data.entry(level).or_default();
data.text = Some(n.to_string());
@@ -685,9 +758,10 @@ impl Plate {
model.touch_measure(level);
model.touch_layout_region(level);
}
None => {
// No level yet and no skull: the seat stays EMPTY, the old painter's behaviour.
(None, false) => {
model.region_data.entry(level).or_default().hidden = true;
model.region_data.entry(skull).or_default().hidden = false;
model.region_data.entry(skull).or_default().hidden = true;
}
}
}
@@ -724,13 +798,24 @@ impl Plate {
}
}
/// 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 {
/// One of the plate's texture regions, with its path and its blend mode — created in the ABI's
/// order by the caller. The mode is not decoration: the ctor sets one on every texture it makes
/// (`0x7703f0`, storing `[texture+0xd0]`), and the glow's is the one that differs — see
/// [`is_unpainted_glow`].
fn texture(
model: &mut Model,
frame: FrameHandle,
layer: DrawLayer,
path: &str,
blend: BlendMode,
) -> 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());
let data = model.region_data.entry(rh).or_default();
data.texture = Some(path.to_string());
data.blend = blend;
rh
}
@@ -738,6 +823,9 @@ fn texture(model: &mut Model, frame: FrameHandle, layer: DrawLayer, path: &str)
/// (`0x7cb438`, `0x7cb4ea`) — and both take `NAMEPLATE_FONT` with the plate's black 1 px drop
/// shadow; the heights arrive with the geometry.
///
/// The black drop shadow is written in [`Plate::lay_out`] instead of here: its step is a
/// WINDOW-derived number ([`PlateGeometry::shadow_offset`]), not a create-time constant.
///
/// `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 —
@@ -751,16 +839,36 @@ fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> Re
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 plate's glow region paints nothing here** — the region is real, shown, textured and ADD,
/// and the quad walk skips it ([`super::extract`]). Decision 0184, and it is the director's call
/// rather than a gap we could close by trying harder.
///
/// The reference draws this region `(SRC_ALPHA, ONE)` over the health bar: an additive
/// reaction-tinted lift of the bar's cavity. 0183 built exactly that and the director rejected it
/// on sight — *"you should not change the gradient or anything — just make the color of the yellow
/// bar brighter"* — so 0184 replaced the rim with [`LIT_BOOST`], and the paint has been the bar's
/// own brighten ever since. That is what this predicate keeps true now that the plate is a widget
/// and its regions go through the shared frame→quad path instead of a painter that simply never
/// emitted a glow quad.
///
/// **Why the mode matters even though nothing is drawn.** `Nameplate-Glow.blp` is DXT1 with **no
/// alpha channel** — every one of its 4096 texels is alpha 255 — and 80.5% of it is exactly
/// `rgb(0,0,0)`, the surround; the signal is a grey rounded bar (rim 140, interior ~46) in rows
/// 20-26 that only reads as a glow when it is *added*. Blitted with straight `BLEND` it is
/// `dst·(1−1) + black·1` — **an opaque black rectangle over the whole plate**, which is precisely
/// what shipped when the widget port left the mode at the ctor default (director report,
/// 2026-09-10; wow-re `nameplate-vkey.md` §8.1 had already named this exact bug and its cause in
/// July). So `GetBlendMode()` answers `"ADD"` truthfully, an addon that re-textures the region
/// gets its own art painted normally, and only the reference's own glow art on an ADD region is
/// the one thing this engine declines to draw.
pub(super) fn is_unpainted_glow(data: &RegionData) -> bool {
data.blend == BlendMode::Add && data.texture.as_deref() == Some(GLOW_TEXTURE)
}
/// 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).
@@ -178,7 +178,7 @@ fn hiding_a_frame_does_not_reopen_the_gate() {
/// re-enter re-measured every line, and the non-empty measure list forced a second full resolve
/// that the gate could never close over.
///
/// It drives the app's real per-frame order (`ui_script::extract::drive_script`): Lua tick →
/// It drives the app's real per-frame order (`ui_script::extract::tick_script`): Lua tick →
/// resolve → measure round-trip → resolve.
#[test]
fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
@@ -216,7 +216,7 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
("Restores 243 health over 21 sec.", 118.0, 24.0),
("Durability 45 / 45", 96.0, 12.0),
];
// One frame of `drive_script`: tick (the re-enter) → resolve → measure round-trip → resolve.
// One frame of `tick_script`: tick (the re-enter) → resolve → measure round-trip → resolve.
// Returns how many strings the font engine was asked to shape this frame.
let frame = |s: &mut UiScript| -> usize {
s.run("reenter()").expect("re-enter");
+129 -3
View File
@@ -38,6 +38,7 @@ fn geometry() -> PlateGeometry {
raid_size: 25.6,
name_height: 12.8,
level_height: 11.0,
shadow_offset: 1.0,
}
}
@@ -52,6 +53,7 @@ fn plate(name: &str, health: f32, max: f32) -> PlateState {
bar_colour: [1.0, 0.0, 0.0],
name: name.to_string(),
level: Some(12),
skull: false,
level_colour: [1.0, 1.0, 0.0],
raid_icon: None,
alpha: 1.0,
@@ -381,6 +383,84 @@ fn the_driver_does_not_overwrite_an_addons_takeover() {
.unwrap());
}
/// **A hovered plate wears no black box** — the defect the widget port shipped (director report,
/// 2026-09-10: "they look totally fucked when I hover or click them"), and the two halves that
/// keep it fixed.
///
/// `Nameplate-Glow.blp` is DXT1 with **no alpha channel** and 80.5% pure-black texels. The
/// reference blends it `ADD` (`push 3` @`0x7cb36a`); blitted with straight `BLEND` it is an opaque
/// rectangle over the entire plate — which is exactly what the ctor's default mode produced here.
/// 0184 is the director's call that benilla paints **no rim at all**, so the region has to be ADD
/// *and* emit nothing, while staying shown and textured for the addons that read it.
///
/// The border assert is not decoration: without it the test would pass on an extract that produced
/// no quads whatsoever.
#[test]
fn a_hovered_plate_emits_no_glow_quad() {
use crate::script::QuadContent;
let mut s = vm();
let mut p = plate("Wolf", 30.0, 40.0);
p.hovered = true;
p.lit = true;
drive(&mut s, &[p]);
// The model tells the truth: shown, textured, and the reference's own blend mode.
assert!(s
.eval::<bool>(
r#"local _, glow = WorldFrame:GetChildren():GetRegions()
return glow:IsShown() == 1
and glow:GetTexture() == "Interface\\Tooltips\\Nameplate-Glow"
and glow:GetBlendMode() == "ADD""#
)
.unwrap());
let paths: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path, .. } => path.clone(),
_ => None,
})
.collect();
assert!(
!paths.iter().any(|p| p.contains("Nameplate-Glow")),
"the hovered plate's glow must not be painted (0184); quads were {paths:?}"
);
assert!(
paths.iter().any(|p| p.contains("Nameplate-Border")),
"the plate's border must still paint; quads were {paths:?}"
);
}
/// The suppression is **the reference's own glow art on an ADD region**, and nothing wider: an
/// addon that re-textures the region gets its art painted like any other texture.
///
/// pfUI's vanilla branch does exactly this — it blanks the six regions and paints its own — so a
/// blanket "plate glow regions never draw" would silently eat an addon's work.
#[test]
fn an_addon_that_retextures_the_glow_gets_its_art_painted() {
use crate::script::QuadContent;
let mut s = vm();
let mut p = plate("Wolf", 30.0, 40.0);
p.hovered = true;
drive(&mut s, &[p]);
s.run(
r#"local _, glow = WorldFrame:GetChildren():GetRegions()
glow:SetTexture("Interface\\AddOns\\pfUI\\img\\glow")"#,
)
.unwrap();
s.resolve();
assert!(s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path, .. } => path.clone(),
_ => None,
})
.any(|p| p.contains("pfUI")));
}
/// 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
@@ -404,14 +484,14 @@ fn the_glow_region_is_the_mouseover_signal() {
.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
/// The level number and the skull share one seat and are mutually exclusive — `skull` 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;
skulled.skull = true;
drive(&mut s, &[skulled]);
assert!(s
.eval::<bool>(
@@ -424,6 +504,52 @@ fn the_skull_replaces_the_level_number() {
.unwrap());
}
/// **A unit whose level has not arrived is not a world boss.** `level: None` with no skull leaves
/// the seat EMPTY — the old painter's behaviour, which the widget port inverted by making one
/// `Option` carry both facts (audit, 2026-09-10).
#[test]
fn a_unit_with_no_level_yet_wears_no_skull() {
let mut s = vm();
let mut unknown = plate("Wolf", 30.0, 40.0);
unknown.level = None;
drive(&mut s, &[unknown]);
assert!(s
.eval::<bool>(
r#"
local _, _, _, level, levelicon = WorldFrame:GetChildren():GetRegions()
return level:IsShown() == nil and levelicon:IsShown() == nil
"#
)
.unwrap());
}
/// **A plate the pool grows on any frame but the first is laid out too.** The lay-out used to ride
/// a per-frame "did the window move?" flag, so a unit that walked into range later got a plate with
/// no size and no anchors: invisible, permanently, and the number of plates on screen was capped at
/// however many were up when V was first pressed (audit, 2026-09-10).
#[test]
fn a_plate_created_after_the_first_frame_is_laid_out() {
let mut s = vm();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
// A second unit arrives on a LATER frame, with no free slot and no geometry change.
drive(
&mut s,
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
);
let (w, h): (f32, f32) = s
.eval("local _, b = WorldFrame:GetChildren() return b:GetWidth(), b:GetHeight()")
.unwrap();
assert_eq!((w, h), (128.0, 32.0), "the second plate has the plate size");
// …and its regions are anchored to it, which is what `GetLeft` answering at all proves.
assert!(s
.eval::<bool>(
r#"local _, b = WorldFrame:GetChildren()
local border = b:GetRegions()
return border:GetLeft() == b:GetLeft() and border:GetRight() == b:GetRight()"#
)
.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.
@@ -1673,6 +1673,153 @@ fn every_installed_region_method_lands_in_a_leaf() {
);
}
/// **The Environment Detail pair — `SetWorldDetail 0x488dd0` / `GetWorldDetail 0x488d70`.**
///
/// Every assertion below is a byte read out of `WoW.exe`, not a shape chosen here; decision 2163
/// carries the carve and `script::cvars::install_world_detail_verbs` the reasoning.
#[test]
fn set_world_detail_writes_the_stop_table_and_validates_like_the_reference() {
let s = script();
s.register_cvars([
(crate::script::CVAR_WORLD_DETAIL, "1"),
(crate::script::CVAR_FRILL_DENSITY, "32"),
]);
let frill = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_FRILL_DENSITY);
let stop = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_WORLD_DETAIL);
// The preset table at `0x804518`, verbatim: {16, 32, 48}.
for (n, want) in [(0, "16"), (1, "32"), (2, "48")] {
s.run(&format!("SetWorldDetail({n})")).unwrap();
assert_eq!(
frill(&s).as_deref(),
Some(want),
"stop {n} writes frillDensity"
);
assert_eq!(
s.eval::<i64>("return GetWorldDetail()").unwrap(),
n,
"and the getter reads that stop back in the SAME tick — the host has not drained yet"
);
}
assert_eq!(
crate::script::WORLD_DETAIL_STOPS,
[16, 32, 48],
"the table is the reference's, not a transcription that can drift"
);
// **Truncation is toward zero** (`0x40a2b0` forces RC = chop), so a fractional stop floors
// toward 0 from both sides — and -0.5 is therefore stop 0, ACCEPTED.
s.run("SetWorldDetail(2.9)").unwrap();
assert_eq!(frill(&s).as_deref(), Some("48"));
s.run("SetWorldDetail(-0.5)").unwrap();
assert_eq!(
frill(&s).as_deref(),
Some("16"),
"-0.5 truncates to 0, which is in range: only <= -1 raises"
);
// **`lua_isnumber` accepts a numeric string.**
s.run(r#"SetWorldDetail("2")"#).unwrap();
assert_eq!(frill(&s).as_deref(), Some("48"));
// Both raise paths, with the reference's own strings.
for bad in [
"SetWorldDetail(3)",
"SetWorldDetail(-1)",
"SetWorldDetail(99)",
] {
let e = s.run(bad).unwrap_err().to_string();
assert!(
e.contains("value must be in the range 0, 2"),
"{bad} must raise 0x8423b8 verbatim, got: {e}"
);
}
for bad in [
"SetWorldDetail()",
"SetWorldDetail(nil)",
r#"SetWorldDetail("x")"#,
"SetWorldDetail({})",
] {
let e = s.run(bad).unwrap_err().to_string();
assert!(
e.contains("Usage: SetWorldDetail(value)"),
"{bad} must raise 0x8423e8 verbatim, got: {e}"
);
}
// Zero return values from the setter; exactly one number from the getter.
s.run("SetWorldDetail(1)").unwrap();
assert_eq!(
s.eval::<usize>("return select('#', SetWorldDetail(1))")
.unwrap(),
0,
"every `ret` in 0x488dd0 leaves eax = 0"
);
assert_eq!(
s.eval::<usize>("return select('#', GetWorldDetail())")
.unwrap(),
1
);
assert_eq!(
s.eval::<String>("return type(GetWorldDetail())").unwrap(),
"number"
);
// The getter reads no argument — pfUI declares its replacement `function(arg)` and the stock
// one it chains to is called with one.
assert_eq!(s.eval::<i64>("return GetWorldDetail(7)").unwrap(), 1);
assert_eq!(stop(&s).as_deref(), Some("1"));
}
/// **pfUI's `hdgraphic` module, run against the real bindings.**
///
/// The module hooks both verbs and drives `frillDensity` past the panel's top stop; this is that
/// arm executed verbatim (`modules/hdgraphic.lua` l.4-39), because "the verbs exist" and "the
/// module works" are different claims and only the second one is the point.
#[test]
fn the_pfui_hdgraphic_extended_arm_runs() {
let s = script();
s.register_cvars([
(crate::script::CVAR_WORLD_DETAIL, "1"),
(crate::script::CVAR_FRILL_DENSITY, "32"),
]);
s.run(
r#"
local HookSetWorldDetail = SetWorldDetail
function SetWorldDetail(arg)
HookSetWorldDetail((arg > 2 and 2 or arg))
if arg > 2 then ConsoleExec("frillDensity " .. (arg+1)*16)
else ConsoleExec("frillDensity 24") end
end
local HookGetWorldDetail = GetWorldDetail
function GetWorldDetail(arg)
local frill = tonumber(GetCVar("frillDensity"))
return frill > 48 and frill/16-1 or HookGetWorldDetail()
end
"#,
)
.unwrap();
// The hook's own precondition: `local Hook... = SetWorldDetail` captured a FUNCTION. Before
// 2163 both were nil and this line took the module down with it.
assert_eq!(
s.eval::<i64>("SetWorldDetail(9); return GetWorldDetail()")
.unwrap(),
9,
"the extended stop round-trips through frillDensity, which is the module's whole point"
);
assert_eq!(
s.cvar(crate::script::CVAR_FRILL_DENSITY).as_deref(),
Some("160"),
"(9+1)*16 — inside the reference's own [1, 256], which is why its range is wider than its slider"
);
// ...and the low arm falls through to the stock getter, which is where a nil hook used to bite.
assert_eq!(
s.eval::<i64>("SetWorldDetail(2); return GetWorldDetail()")
.unwrap(),
2
);
}
/// **The four nameplate verbs take no argument and return nothing.**
///
/// `ShowNameplates 0x489450` / `HideNameplates 0x489460` / `ShowFriendNameplates 0x489470` /
+3 -1
View File
@@ -529,7 +529,9 @@ pub const SELF_FADE_HIDE: f32 = 0.00278;
/// distance, `α = (1 − cos(π·D/F))/2` over `D = dist − nearclip ∈ (SELF_FADE_HIDE, window]`. Below
/// [`SELF_FADE_HIDE`] above the near clip the model hard-hides (`0.0`); at/after `window` it's opaque
/// (`1.0`). `nearclip` is the camera's near-plane distance — the fade completes exactly as the near
/// plane would begin to slice the model, which is why the two are coupled (see `crate::view::CAM_NEAR`).
/// plane would begin to slice the model, which is why the two are coupled — the caller passes the
/// LIVE `nearclip` ([`crate::view::ViewDistance::nearclip`]), not a constant, so a player who moves
/// the near plane moves the fade with it (2163).
///
/// Unlike [`doodad_fade_alpha`] (horizontal *world* distance, size-bucketed) this is the *camera*
/// distance to a single tracked object; both feed the same per-instance render-alpha channel.
+165 -16
View File
@@ -1,7 +1,8 @@
//! **The view** — who is looking, with what optics, and how far the detailed world is drawn.
//!
//! Three things, one owner. [`WorldCamera`] marks *the* camera the scene is rendered through;
//! [`CAM_NEAR`] and [`CAM_FOVY`] are its optics; [`ViewDistance`] is the faithful `farclip`.
//! [`CAM_FOVY`] is its fixed optic; [`ViewDistance`] is the faithful `nearclip`/`farclip` **pair**,
//! which is the shape the reference keeps them in (2163).
//!
//! The marker and the optics were in `player::camera` until decision 1160's stage zero, and that
//! was the single largest edge across the engine/game line: **26 engine files** — terrain
@@ -102,9 +103,29 @@ impl Viewer {
/// but it is the *maximum*, and it is what every player gets before they touch anything: at 777 the
/// residency window is 24 chunks per axis against 350's 11 ([`terrain_stream::window::inner_radius`]),
/// ~4.8x the area streamed, drawn and held resident. The slider still reaches 777.
///
/// **`nearclip` is the second half of the same resource, because the reference stamps them
/// together** (2163). Its per-frame camera outer `0x511bc0` reads both CVar records and writes the
/// camera in four instructions, unconditionally, before the `[cam+0x48]` branch that picks the
/// cinematic leg:
///
/// ```text
/// 511bcf mov eax,[0xbe1078] ; the `nearclip` record handle (cached by 0x50b728's Lookup)
/// 511bd4 fld dword [eax+0x24] ; the record's float
/// 511bdc fstp dword [esi+0x38] ; cam near
/// 511bdf mov ecx,[0xbe0cc0] ; the `farclip` record handle
/// 511be5 fld dword [ecx+0x24]
/// 511be8 fstp dword [esi+0x3c] ; cam far
/// ```
///
/// So `nearclip` is a **live** setting there, not dead plumbing — see [`NEARCLIP_RANGE`] for the
/// half-truth this field replaced.
#[derive(Resource, Clone, Copy)]
pub struct ViewDistance {
pub farclip: f32,
/// The camera **near-plane** distance in yards — WoW's `nearclip` CVar, re-stamped onto the
/// projection every frame by [`stamp_near_clip`] exactly as `0x511bc0` re-stamps `[cam+0x38]`.
pub nearclip: f32,
}
/// The settable range of [`ViewDistance::farclip`] — the vanilla `farclip` CVar clamp `[177, 777]`
@@ -114,6 +135,28 @@ pub struct ViewDistance {
/// gone and the window now follows this number, so the headroom went with it (1513).
pub const FARCLIP_RANGE: std::ops::RangeInclusive<f32> = 177.0..=777.0;
/// The settable range of [`ViewDistance::nearclip`] — the vanilla `nearclip` CVar clamp, read off
/// its own change callback `0x688d90` the way [`FARCLIP_RANGE`] is read off `0x688d40`: the two
/// bounds are the f32s at `[0x8029d0]` = `0.01` and `[0x808300]` = `0.33`, and a value outside them
/// is **refused** (the callback returns 0 after echoing `0x869abc` `"NearClip must be in range 0.01
/// - 0.33"`), not clamped. Our apply clamps instead, which is this table's standing posture for
/// every range (the consumer clamps at its own edge — `script::cvars`' module doc).
///
/// **Positive control for that decode:** the same shape at `0x688d40` yields `[0x81021c]` = `177`
/// and `[0x80fed8]` = `777`, which is [`FARCLIP_RANGE`] as it has stood since 1624.
///
/// **This const is the correction of a verified-but-partial finding** (2163). [`NEARCLIP_DEFAULT`]
/// used to be a hardcoded `1.0 / 9.0` named `CAM_NEAR`, documented as "the reference's own 1/9,
/// hardcoded in its camera ctor (`0x50a6c0`: `+0x38 = 0x3de38e39`) — the `nearclip` console cvar
/// stores to a global with zero readers, dead plumbing". Every clause of that is true and the
/// conclusion is wrong: the *derived global* `[0xc7b480]` is indeed dead (wow-re
/// `cvar/scratch/graphics-cost-cvar-census.md` §8 lists it), but the camera never reads that global
/// — it reads the **record**, every frame, at `0x511bd4`. So the ctor's 1/9 is the value the camera
/// holds for exactly as long as it takes the first frame's outer to overwrite it, and is never
/// rendered with. `worldview`'s own near plane had already drifted to `0.1` under a doc claiming it
/// was "kept in step by hand" with the 1/9 — the drift was the tell.
pub const NEARCLIP_RANGE: std::ops::RangeInclusive<f32> = 0.01..=0.33;
/// The world camera's multisampling level — WoW's **`gxMultisample`** CVar.
///
/// Held on the reference's own scale: a **sample count**, clamped to [`MSAA_RANGE`] (`[1, 16]`,
@@ -361,6 +404,20 @@ impl Plugin for MsaaSupportPlugin {
/// function of anything: the detailed world ends at `farclip` by the wall, never by this plane.
pub const CAM_FAR: f32 = 3000.0;
impl ViewDistance {
/// Set the near plane from a `nearclip` write, under the reference's own bounds.
///
/// **A method and not a public [`NEARCLIP_RANGE`] for the CVar host to clamp against**, which
/// is how `farclip` does it — because `farclip`'s range has a second consumer (the Terrain
/// Distance slider is built with those bounds) and this one does not, and `benilla-app`
/// naming one more engine item is what `tests/world_api_wall.rs` exists to make expensive.
/// The knob owning its own clamp is also the shape `ClutterConfig::set_frill_density` settled
/// on for the same question (2151).
pub fn set_nearclip(&mut self, v: f32) {
self.nearclip = v.clamp(*NEARCLIP_RANGE.start(), *NEARCLIP_RANGE.end());
}
}
impl Default for ViewDistance {
/// `$WOW_FARCLIP` (yd, clamped to [`FARCLIP_RANGE`]) overrides the 350 default. The options row is
/// the live lever, but a headless capture has no hands — and a horizon or fog report almost always
@@ -374,7 +431,10 @@ impl Default for ViewDistance {
.map_or(350.0, |v| {
v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end())
});
Self { farclip }
Self {
farclip,
nearclip: NEARCLIP_DEFAULT,
}
}
}
@@ -414,19 +474,6 @@ pub fn within_farclip(
#[derive(Component)]
pub struct WorldCamera;
/// The camera **near-plane** distance (yd) — the reference's own **1/9**, hardcoded in its camera
/// ctor (`0x50a6c0`: `+0x38 = 0x3de38e39`; the `nearclip` console cvar stores to a global with zero
/// readers — dead plumbing; wow-re `water-frame-straddle` §4d). Shared by the projection (the camera spawn)
/// and the self-avatar fade's `nearclip` ([`crate::model_fade::self_model_fade_alpha`]) so the model
/// finishes fading exactly as the near plane would begin to slice it — the reference couples the
/// two the same way (`cam+0x38 ≈ 0.1`, set per frame in the driver `0x511bc0`).
///
/// It was 1.0 from 0062 to 0905 "for depth precision" — a rationale that predates knowing the
/// pipeline: the projection is `perspective_infinite_reverse_rh` on a float depth buffer
/// ([`crate::capture::depth_probe`]'s tests draw with the real one), where `depth = near/z` makes
/// relative precision — and our ULP-relative bias ladder ([`crate::sky_order`]) — independent of
/// the near value. The small near is what keeps the whole waterline-crossing band (the corner-min
/// submersion probe, `liquid::detect_submersion`) inches tall instead of a yard.
/// The `$WOW_MSAA` startup knob → a sample count, shared by both world-camera spawners
/// (`benilla_world::worldview` and the app's `player::setup`) so the two can never disagree.
///
@@ -456,7 +503,53 @@ pub fn msaa_from_env() -> bevy::render::view::Msaa {
}
}
pub const CAM_NEAR: f32 = 1.0 / 9.0;
/// The **registered default** of the `nearclip` CVar — `"0.1"`, the default string at `0x84fb48`
/// passed by `CVar::Register 0x63db90` at `0x68867a` (name `0x84ffb0` `"nearclip"`, help
/// `"Near clip plane distance"`, callback `0x688d90`, record `[0xc7f348]`; wow-re
/// `re/cvar/cvar-register-sites.tsv` row 187). 1804's law, so this is a `Same` row, not a choice.
///
/// The live value is [`ViewDistance::nearclip`]; this is only where it starts and what the
/// off-world spawners ([`crate::worldview`], the depth probe) use when there is no CVar table.
///
/// **Why the number matters even though it is a tenth of a yard.** It was `1.0` from 0062 to 0905
/// "for depth precision" — a rationale that predates knowing the pipeline: the projection is
/// `perspective_infinite_reverse_rh` on a float depth buffer (the app's `capture::depth_probe`
/// tests draw with the real one — it lives a crate up, so this is deliberately not a doc link),
/// where `depth = near/z` makes relative precision — and our
/// ULP-relative bias ladder ([`crate::sky_order`]) — independent of the near value. The small near
/// is what keeps the whole waterline-crossing band (the corner-min submersion probe,
/// `liquid::detect_submersion`) inches tall instead of a yard. It is also the distance the
/// self-avatar fade completes over ([`crate::model_fade::self_model_fade_alpha`] takes it as
/// `nearclip`), so the model finishes fading exactly as the near plane would begin to slice it —
/// the coupling the reference has for free, both being `[cam+0x38]`.
pub const NEARCLIP_DEFAULT: f32 = 0.1;
/// **The reference's `0x511bc0`** — re-stamp the world camera's near plane from the live `nearclip`
/// every frame, so a `SetCVar`/`/console nearclip` write reaches this frame's picture the way it
/// does there (2163). The far plane is deliberately NOT stamped here: ours is [`CAM_FAR`], the
/// horizon plane, and `farclip` is the wall — 0684's split, which the reference does not have.
///
/// Cheap enough to run unconditionally (one resource read, one component write on one entity), and
/// unconditional is also what the reference does — it re-stamps on every frame, changed or not.
pub fn stamp_near_clip(
view: Res<ViewDistance>,
mut cam: Query<&mut Projection, With<WorldCamera>>,
) {
for mut proj in &mut cam {
// Read through `as_ref` first: taking `&mut` on a Bevy component marks it changed whether
// or not the write differs, and a projection that reports "changed" every frame is a lie
// every downstream `Changed<Projection>` has to pay for.
let Projection::Perspective(current) = proj.as_ref() else {
continue;
};
if current.near == view.nearclip {
continue;
}
if let Projection::Perspective(p) = &mut *proj {
p.near = view.nearclip;
}
}
}
/// The camera's vertical field of view (radians) — one constant shared by the projection
/// (the camera spawn) and every consumer that needs the near rectangle's true shape. 45°, the value the
/// projection has always used (Bevy's `PerspectiveProjection` default, ≈ the reference's 44.1° —
@@ -508,6 +601,16 @@ impl Plugin for ViewPlugin {
}
pub(crate) fn plugin(app: &mut App) {
// The reference's per-frame clip stamp (2163). In `Update` and not `PostUpdate` so a
// `SetCVar("nearclip", …)` drained this frame reaches this frame's projection, which is the
// ordering `0x511bc0` has for free by running inside the world-frame driver.
//
// The resource is init'd HERE and not only in `world_plugins`: a system's own plugin owes it
// its parameters, and a harness that adds this plugin for the pose set (`view::tests`) got a
// "Resource does not exist" panic instead. `init_resource` is idempotent, so the app's own
// registration still wins wherever it already ran.
app.init_resource::<ViewDistance>()
.add_systems(Update, stamp_near_clip);
app.add_systems(
Update,
publish_camera_pose
@@ -524,6 +627,52 @@ mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
/// **`0x511bc0`'s near stamp** — the live `nearclip` reaches the projection, and a projection
/// already holding it is not marked changed for the privilege (2163).
#[test]
fn the_near_plane_follows_the_cvar_and_settles() {
let mut app = App::new();
app.add_systems(Update, stamp_near_clip);
let cam = app
.world_mut()
.spawn((
WorldCamera,
Projection::from(PerspectiveProjection {
near: NEARCLIP_DEFAULT,
..default()
}),
))
.id();
// A player (or an addon's `ConsoleExec("nearClip 0.3")`) moves the knob.
app.insert_resource(ViewDistance {
farclip: 350.0,
nearclip: 0.3,
});
app.update();
let near = |app: &App| match app.world().get::<Projection>(cam).unwrap() {
Projection::Perspective(p) => p.near,
_ => unreachable!("spawned perspective"),
};
assert_eq!(near(&app), 0.3, "the stamp is the reference's, every frame");
// ...and the SECOND frame, with nothing moved, must not report the projection changed:
// `&mut` on a Bevy component marks it whether or not the write differs. (Verified by
// mutation: dropping the `as_ref` guard in `stamp_near_clip` fails exactly this line.)
let tick = app.world().read_change_tick();
app.update();
assert_eq!(near(&app), 0.3);
let now = app.world().read_change_tick();
assert!(
!app.world()
.entity(cam)
.get_ref::<Projection>()
.unwrap()
.last_changed()
.is_newer_than(tick, now),
"an unchanged near plane must not dirty the projection"
);
}
/// The contract of [`CameraPoseSet`]: a system ordered after it, in the same `Update`, reads
/// the pose the controller wrote **this** frame — not the one Bevy will propagate at the end
/// of it. Written as the frame it fixes: move the camera the way a teleport snap does, and
+5 -3
View File
@@ -71,9 +71,11 @@ fn view_start() -> [f32; 3] {
/// to clear the valley floor, which is what the viewer has always opened above.
const VIEW_OPEN_Z: f32 = 100.0;
/// Near plane, in yards — the client's ([`crate::view::CAM_NEAR`]) value, kept in step by hand
/// until the camera itself moves engine-side (decision 1160, stage zero).
const NEAR: f32 = 0.1;
/// Near plane, in yards. The viewer has no CVar table, so it opens at the `nearclip` CVar's
/// registered default and stays there — **read from the const now, not copied** (2163): "kept in
/// step by hand" is what this line used to say while holding `0.1` against a `CAM_NEAR` of `1/9`,
/// and the drift is what showed the 1/9 was wrong in the first place.
const NEAR: f32 = crate::view::NEARCLIP_DEFAULT;
/// Vertical FOV in radians — vanilla's 90° horizontal at 4:3.
const FOVY: f32 = 1.221_730_5;