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:
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user