diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index c3c76420..4879e46a 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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" }, diff --git a/crates/benilla-app/src/bindings.rs b/crates/benilla-app/src/bindings.rs index a189f10d..b1d863e6 100644 --- a/crates/benilla-app/src/bindings.rs +++ b/crates/benilla-app/src/bindings.rs @@ -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, capture: Res, hover: Res, - over_ui: Res, + // The CHROME flag — its only reader here is the wheel branch, which says why. + over_ui: Res, dispatch: Res, mut state: ResMut, mut same_vm: Local>, @@ -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::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .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::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .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::().0 = true; + app.world_mut().resource_mut::().0 = true; app.world_mut().write_message(MouseWheel { unit: MouseScrollUnit::Line, x: 0.0, diff --git a/crates/benilla-app/src/capture/depth_probe.rs b/crates/benilla-app/src/capture/depth_probe.rs index d537cef6..891b071a 100644 --- a/crates/benilla-app/src/capture/depth_probe.rs +++ b/crates/benilla-app/src/capture/depth_probe.rs @@ -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, ) } diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 4e7c5a12..040d6f5a 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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; diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index c0adcb02..c7e612e8 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -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); }) diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index f73a1a97..d77c394f 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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. diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index bbc6993f..f0abc085 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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). diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index cc39c7bd..af3cd490 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -39,7 +39,7 @@ fn log_path() -> Option { } } -/// 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 = std::sync::OnceLock::new(); *ON.get_or_init(|| log_path().is_some()) diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 0f42eb16..941cb60b 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -1,7 +1,7 @@ //! The HUD minimap renderer (decision 0203 phase 1) — the app half of the `` 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, windows: Query<&Windo }); } -/// This frame's extracted `` widget slot, written by `ui_script::extract::drive_script` (the +/// This frame's extracted `` 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); diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 9efed023..52b382d8 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -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>, - pointer_over_ui: Res, - // **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, + pointer_over_ui: Res, mut rig: ResMut, cameras: Query<&Camera, With>, window: Single<&Window, With>, @@ -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 diff --git a/crates/benilla-app/src/player/camera_channel.rs b/crates/benilla-app/src/player/camera_channel.rs index 6d6e55b6..c1805441 100644 --- a/crates/benilla-app/src/player/camera_channel.rs +++ b/crates/benilla-app/src/player/camera_channel.rs @@ -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::*; diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index deaa7811..27c47e56 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -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, 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, - 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 { - 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); + } } diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 3e219fac..6465c219 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -38,6 +38,10 @@ pub(super) fn control( Res, // The four 1.12 camera option toggles and their numeric siblings (decision 2149). Res, + // `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, ), // 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 diff --git a/crates/benilla-app/src/player/setup.rs b/crates/benilla-app/src/player/setup.rs index 9ce61784..0346de66 100644 --- a/crates/benilla-app/src/player/setup.rs +++ b/crates/benilla-app/src/player/setup.rs @@ -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() }), diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index bc85e370..22bd823f 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -1052,7 +1052,7 @@ pub(crate) struct BoothPanes(pub(crate) HashMap); /// 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> { diff --git a/crates/benilla-app/src/screenshot.rs b/crates/benilla-app/src/screenshot.rs index d5a2cabc..d6d8431a 100644 --- a/crates/benilla-app/src/screenshot.rs +++ b/crates/benilla-app/src/screenshot.rs @@ -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). diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index a8eecc0a..59a47174 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -650,7 +650,7 @@ impl Plugin for UiModelsPlugin { .init_non_send_resource::() .add_systems(Startup, setup_tiles) // **Before the extract**, which is where a `` 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 diff --git a/crates/benilla-app/src/ui_script/cast_tests.rs b/crates/benilla-app/src/ui_script/cast_tests.rs index 7d94a834..59ffa1db 100644 --- a/crates/benilla-app/src/ui_script/cast_tests.rs +++ b/crates/benilla-app/src/ui_script/cast_tests.rs @@ -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 { s.tick(dt); s.resolve(); diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index a043d61e..d2cfde64 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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>, window: Query<&Window, With>, // 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>, mut ui_clock: ResMut, - mut quads: ResMut, - world_assets: Option>, - mut images: ResMut>, mut font_atlas: Option>, - // 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, - ), - // The append-lane hole this pass parks for its UiQuadAppend producer: the `` 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, + ui_cost_wanted: Res, // 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, // The uiScale dial folded into the seam scale (decision 0584). ui_scale: Res, - // ── 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>, // This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`). mut ui_cost: ResMut, + // What the paint half needs from this one — and whether it may run at all. + mut pass: ResMut, ) { - 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>, + window: Query<&Window, With>, + mut quads: ResMut, + world_assets: Option>, + mut images: ResMut>, + mut font_atlas: Option>, + // 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, + ), + // The append-lane hole this pass parks for its UiQuadAppend producer: the `` 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, + // ── 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>, + // This frame's phase split, published for whoever asked (the `[ui-cost]` line, `hover_log`). + mut ui_cost: ResMut, + // Whether the tick half ran this frame, and what it derived. + pass: Res, +) { + 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::(); + // The pass's own handover (2168) — the tick half writes it, the paint half reads it. + app.init_resource::(); app.init_resource::>(); app.init_resource::(); app.init_resource::(); @@ -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::(); + // The pass's own handover (2168) — the tick half writes it, the paint half reads it. + app.init_resource::(); app.init_resource::>(); app.init_resource::(); app.init_resource::(); @@ -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::().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::(); + // The pass's own handover (2168) — the tick half writes it, the paint half reads it. + app.init_resource::(); app.init_resource::>(); app.init_resource::(); app.init_resource::(); @@ -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 } diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index d2630708..eaa3b828 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -18,7 +18,7 @@ static TEXT_PROBE: std::sync::LazyLock = 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, diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index ac7a6f82..7a833d7d 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -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). diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 171af5a2..7fc4ac27 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index b93935e1..02d23056 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -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::(); + // The pass's own handover (2168) — the tick half writes it, the paint half reads it. + app.init_resource::(); app.init_resource::>(); app.init_resource::(); app.init_resource::(); @@ -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(), diff --git a/crates/benilla-app/src/ui_script/mirror_timer_tests.rs b/crates/benilla-app/src/ui_script/mirror_timer_tests.rs index 55382f7c..0003eb20 100644 --- a/crates/benilla-app/src/ui_script/mirror_timer_tests.rs +++ b/crates/benilla-app/src/ui_script/mirror_timer_tests.rs @@ -89,7 +89,7 @@ fn caption(s: &UiScript, frame: &str) -> Option { .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 { s.tick(dt); s.resolve(); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index a4916472..4368d878 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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`'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::() .init_resource::() .init_resource::() @@ -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::() + .init_resource::() .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>) -> bool fn arbitrate_pointer_over_ui( egui: Option>, hover: Res, + plate_hover: Res, mut over: ResMut, + mut panel: ResMut, ) { // 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` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and +/// `Time` 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>, @@ -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::() + .init_resource::() + .init_resource::() + .init_resource::() + .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::().0 = Some(7); + app.world_mut() + .resource_mut::() + .0 = Some(plate); + app.update(); + assert!( + app.world().resource::().0, + "the plate takes the pointer" + ); + assert!( + !app.world().resource::().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::().0 = Some(7); + app.update(); + assert!(app.world().resource::().0); + assert!(app.world().resource::().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::().0); + assert!(!app.world().resource::().0); + } +} diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 842fe9c7..facb360b 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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: diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index e96f1e1e..f1986109 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -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` diff --git a/crates/benilla-app/src/ui_text/engine/gpu.rs b/crates/benilla-app/src/ui_text/engine/gpu.rs index 31112869..a00a001a 100644 --- a/crates/benilla-app/src/ui_text/engine/gpu.rs +++ b/crates/benilla-app/src/ui_text/engine/gpu.rs @@ -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); diff --git a/crates/benilla-app/src/ui_text/layout/mod.rs b/crates/benilla-app/src/ui_text/layout/mod.rs index 7a763775..ff1184ff 100644 --- a/crates/benilla-app/src/ui_text/layout/mod.rs +++ b/crates/benilla-app/src/ui_text/layout/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_text/pack.rs b/crates/benilla-app/src/ui_text/pack.rs index 6ce17024..d50c5bc2 100644 --- a/crates/benilla-app/src/ui_text/pack.rs +++ b/crates/benilla-app/src/ui_text/pack.rs @@ -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, } +/// 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 `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) { + 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 { 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 diff --git a/crates/benilla-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index c7c090af..a756ce06 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -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() diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index be0e8a89..f9eb631f 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -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::().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::().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::().expect("model app_data"); + let stop = model + .cvars + .get(&CVAR_WORLD_DETAIL.to_ascii_lowercase()) + .and_then(|slot| slot.value.parse::().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). diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 0b9dfda8..57e45dec 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 7a1f20dd..f0c1ea0f 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index 3ef06410..d702b288 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -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, + /// 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, + /// **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, } 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). diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 0c8d3542..73ec05b7 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -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"); diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index eb18a2c8..87fba6aa 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -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::( + 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 = 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::( @@ -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::( + 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::( + 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. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 533da8cc..127a17c5 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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::("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::("return select('#', SetWorldDetail(1))") + .unwrap(), + 0, + "every `ret` in 0x488dd0 leaves eax = 0" + ); + assert_eq!( + s.eval::("return select('#', GetWorldDetail())") + .unwrap(), + 1 + ); + assert_eq!( + s.eval::("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::("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::("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::("SetWorldDetail(2); return GetWorldDetail()") + .unwrap(), + 2 + ); +} + /// **The four nameplate verbs take no argument and return nothing.** /// /// `ShowNameplates 0x489450` / `HideNameplates 0x489460` / `ShowFriendNameplates 0x489470` / diff --git a/crates/benilla-world/src/model_fade.rs b/crates/benilla-world/src/model_fade.rs index fddbd207..cc50d065 100644 --- a/crates/benilla-world/src/model_fade.rs +++ b/crates/benilla-world/src/model_fade.rs @@ -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. diff --git a/crates/benilla-world/src/view.rs b/crates/benilla-world/src/view.rs index ae161a4a..18e7d756 100644 --- a/crates/benilla-world/src/view.rs +++ b/crates/benilla-world/src/view.rs @@ -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 = 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 = 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, + mut cam: Query<&mut Projection, With>, +) { + 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` 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::() + .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::(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::() + .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 diff --git a/crates/benilla-world/src/worldview.rs b/crates/benilla-world/src/worldview.rs index 84439b58..2385ddc4 100644 --- a/crates/benilla-world/src/worldview.rs +++ b/crates/benilla-world/src/worldview.rs @@ -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;