From 6cab3e93805be971fc57dac75ec5b02bd0ed419a Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 6 Sep 2026 19:36:25 +0700 Subject: [PATCH] the far rain stops going black, and a seated body stays on its seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - black rain is a zero-padded mip: `RainDrop01.blp` stores its 2×16 and 1×8 DXT3 levels as one block each where the block grid needs four and two, our decoder zero-padded the rest — an all-zero BC2 block is black at alpha 0 — and the rain's Mod2x lane reads no alpha, so every far streak that minified onto levels 3 and 4 (three-quarters and half black by the census) multiplied the scene toward black from ~43 yd, where level 3 enters the trilinear blend, to the fog at 70; the reference sizes a stored level at `bpp·max(4,w)·max(4,h)/8` bytes from its file offset and the header's `mipSizes` never sizes an upload, so a short level is completed by the next levels' own grey blocks — `[L3 | L4 | L5 | L6]` — and `Header::dxt_level_span` reads that span on both decode entry points, CPU and native byte-identical, with `pad_to` kept only for a file that ends inside a level, which no shipped asset does (543 of 32,309 DXT files carry a short level, every one non-square, none past EOF); not a mip cap and not a grey fill — the file's next bytes are what the reference draws; wow-re's cross-check settled which arm is which: the block copier `0x5a5780` is live on the shipped Direct3D default (`0x5a0a80` → `0x5a4f60`'s cell `[6][6]`), the upload's `imageSize` at `0x59f4e6`–`0x59f515` over the zero-copy pointer (`0x5a8555`) on OpenGL, the same bytes either way — and the `GL_TEXTURE_MAX_LOD` clamp is dead on every device (its gate `[0xc0f274]` has one writer, a zeroed `ebx`), so every tail level is sampled; why it surfaced now: 1642/1645's isotropic trilinear takes the streak's width for its LOD where the retired anisotropy 8 took its length, which carried the far streaks onto level 3 inside the fog window for the first time; `benilla-extract blp --mips` writes every authored level and prints the per-level texel census, and a real-asset test pins the copy byte for byte (B358/B225, 2020, 2024) - the cooldown sweep draws again: the extract's `ModelPane` arm drew the atlas cell it found in the bridge, but the cell is packed by `sync_tiles` AFTER the extract and the conversion is memoized on the engine's list — so a cooldown armed on a quiet interface extracted once with no cell, the cell arrived a frame later, nothing re-ran the conversion, and the tile lived on the atlas for the whole cooldown with no quad on screen (the 2013 harness test had re-pinged the minimap to force a second conversion, encoding the defect as the contract); the arm now publishes the request — rect, paint key, alpha, clip beside the unit ladder — and `compose_tiles` appends one premultiplied quad per cell packed THIS frame in the `UiQuadAppend` lane, a function of the bridge alone; the second defect behind it: 2019's rotation rows were uploaded to the world's shared light buffer only, while a tile's material twin binds the tile's own black-light buffer whose mat-anim region is zero-initialised, so every quadrant read the identity — and `cooldown.blp` is 0.6-alpha black outside a four-texel strip, so the sweep would have drawn as a static disc and never cleared; `MatAnimMirrors` now carries the region into every buffer a lane binds under one generation gate; measured six seconds into a 30 s stance cooldown, the button rects read means of 79 and 22 against 150 and 48 before, the top-right wedge cleared from 12 toward 3 o'clock; `WOW_TILE_TRACE=1` is the tile probe (2023) - a body inside a GameObject's hull is left where the server put it: the reference's resolver is a time-of-impact sweep and nothing else — no push-out stage anywhere in `0x634040`'s closure, and `0x632830`'s clip drops a candidate face whose clipped vertices all sit more than 1/36 yd (`[0x7ff9c8]`) behind the prism's leading plane — where avian's slide opened and closed with a Gauss–Seidel push-out whose minimum translation for a tall capsule in a small box is horizontal, shoving a seated body 0.555 yd off its stool on the first frame after the server seated it (B359); the `depenetrate` stage is deleted rather than disabled, and because the deletion alone left the body unable to walk out (a straddled seat top read as a wall at t = 0) the sweep carries the reference's band as `BACKFACE_BAND`, measured against the face's vertices, so a body walks off a stool and still stands on a sloped floor its feet sit a few centimetres under; the creature clamp cast its down-ray from 2.5 yd above the seat, a probe the reference never casts — its settle sweeps DOWN from the current position (`0x636dcd`–`0x636e45`) and the only way up is the step-up after a blocking horizontal hit — which found a cage's lid as Galen Goodward's floor (B372; seat 22.416, ray from 24.916, lid 24.667): a walker continuing its path now runs the shared `grounded_step` from where it stood with a creature's own 2.0 yd rise budget (`[0x801628]`) and takes only the Y, an idle unit casts down from its seat with the 1/36 band above it, and continuity is one server path long since `0x7c6420` re-bases on every packet; the `clmp` trace tag (`WOW_CLAMP_TRACE`) reads one clamp cast and the `move` tag marks input-less travel as CREEP (2018); and a seated body is not re-faced by the mouse: the reference's mouse-MOVE handler `0x514400` rotates the camera unconditionally and hands its facing to the body only through `0x5145e0`, whose last conjunct is `GetStandState() == 0` (`0x51460c`, the client-predicted cache), with `0x5151b0` re-testing the descriptor byte — so a right-drag while sitting orbits the camera round a body that keeps its facing and sends nothing; benilla restored the facing on the stun/death predicate only and spun a seated body in its chair with every sample, streaming a `SET_FACING` each; `MoverInput::mouse_may_turn_body` carries the stand-state term, and the keyboard turn keeps `may_turn` alone because that path stands the body (2025) - the vendor window's face and name: the stock `MerchantFrame.lua` takes its title from `UnitName("NPC")` and its face from `SetPortraitTexture(MerchantFramePortrait, "NPC")` — the `"npc"` token, `CGGameUI`'s `[0xb4e2d0]` that every NPC window reads on its own `*_SHOW` — and that seam had four defects stacked on it: `feed_interact_npc` ran a frame ahead of the net apply, so the frame `SMSG_LIST_INVENTORY` raised `MERCHANT_SHOW` still held the previous NPC (the Goldshire inn titled "Innkeeper Farley" over Brog Hamfist's stock); `feed_units`' dirty gate had no interact term, so a swap to a second vendor moved no descriptor and skipped the feed; the memo row was never written, so `UnitExists("npc")` stayed true after a close; and `SetPortraitTexture` stored its token raw where the booth slots are keyed lowercase — the reference resolves every unit token through `0x515970`, whose ten compares are all `_strnicmp`, so the binding folds on the way in; the writer now seats itself `.after(apply_net_updates).before(drive_display_facing)`, pinned in the built schedule graph rather than the source, the gate takes `interact.is_changed()`, the memo is written, and the fold lands at the one seam where a Lua token becomes a slot name — every NPC window reads the same token through the same chain; `WOW_PROBE_VENDOR_SWAP=1` stands between Brog Hamfist and Barkeep Dobbins, 5.45 yd apart, reads the name inside `MERCHANT_SHOW`, opens one over the other and closes it — five FAILs before, five PASSes after (2022) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/capture/mod.rs | 2 + .../benilla-app/src/capture/probe_service.rs | 18 +- .../src/capture/probe_vendor_swap.rs | 426 ++++++++++++ crates/benilla-app/src/dev.rs | 8 + crates/benilla-app/src/net.rs | 18 +- crates/benilla-app/src/net/apply.rs | 2 +- crates/benilla-app/src/net/motion/remote.rs | 3 + crates/benilla-app/src/net/motion/spline.rs | 615 ++++++++++++++++-- crates/benilla-app/src/player.rs | 12 +- crates/benilla-app/src/player/controller.rs | 23 +- crates/benilla-app/src/player/move_trace.rs | 22 +- crates/benilla-app/src/player/mover.rs | 54 +- crates/benilla-app/src/player/state.rs | 78 ++- crates/benilla-app/src/player/step_probe.rs | 2 +- crates/benilla-app/src/ui_models/mod.rs | 232 ++++++- .../benilla-app/src/ui_script/extract/mod.rs | 43 +- .../src/ui_script/minimap_tests.rs | 80 ++- crates/benilla-app/src/ui_session.rs | 112 +++- crates/benilla-app/src/ui_unit.rs | 106 ++- crates/benilla-app/tests/world_api_wall.rs | 9 +- crates/benilla-blp/src/lib.rs | 172 ++++- .../src/bin/benilla-extract/main.rs | 39 +- crates/benilla-formats/src/lib.rs | 145 +++++ crates/benilla-ui/src/script/region.rs | 10 +- crates/benilla-ui/src/script/tests/regions.rs | 34 + .../benilla-world/src/collision/one_sided.rs | 261 ++++++-- crates/benilla-world/src/mat_anim_table.rs | 51 +- 27 files changed, 2334 insertions(+), 243 deletions(-) create mode 100644 crates/benilla-app/src/capture/probe_vendor_swap.rs diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 62a099c8..8b3edc4e 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -109,6 +109,7 @@ mod probe_partner; mod probe_rig; mod probe_service; mod probe_taxi; +mod probe_vendor_swap; mod probes; mod scenarios; use crate::run_mode::CaptureMode; @@ -136,6 +137,7 @@ pub(crate) use probe_partner::ProbePartnerPlugin; pub(crate) use probe_rig::ProbeRigPlugin; pub(crate) use probe_service::ProbeServicePlugin; pub(crate) use probe_taxi::ProbeTaxiPlugin; +pub(crate) use probe_vendor_swap::ProbeVendorSwapPlugin; pub(crate) use probes::{ fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin, JitterMeterPlugin, LiftCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, diff --git a/crates/benilla-app/src/capture/probe_service.rs b/crates/benilla-app/src/capture/probe_service.rs index 2d6a18c5..9cb26d7e 100644 --- a/crates/benilla-app/src/capture/probe_service.rs +++ b/crates/benilla-app/src/capture/probe_service.rs @@ -90,15 +90,15 @@ const SCAN_TIMEOUT_SECS: f64 = 20.0; const WINDOW_TIMEOUT_SECS: f64 = 20.0; /// How long the re-click gate's latch may lag the window it belongs to. /// -/// It is allowed to lag **at all** because `feed_interact_npc` and the net apply are both in -/// `WorldStage::Net` and deliberately unordered against each other (`ui_session`, decision 1741's -/// note: "whether a window's first frame is seen now or next frame is invisible"), so the token can -/// arm one frame after the window opens. A human's second right-click is a hundred milliseconds and -/// many frames away, so one frame is invisible — but "one frame" and "never" are the same reading -/// if you sample once, which is exactly what the first run of this assert did: it failed the quest -/// and trainer legs, whose windows open on the packet, and passed the gossip legs only because the -/// gossip frame holds shut for several frames waiting on its greeting query (B292). So the probe -/// polls, and prints how long it actually took. +/// It no longer lags by construction: `feed_interact_npc` is seated after the net apply since +/// decision 2022, so the token arms on the very frame the window opens. It USED to be allowed to +/// lag a frame (`ui_session` was "deliberately unordered" against the apply, on decision 1741's +/// reading that a window's first frame is invisible) — and "one frame" and "never" are the same +/// reading if you sample once, which is exactly what the first run of this assert did: it failed +/// the quest and trainer legs, whose windows open on the packet, and passed the gossip legs only +/// because the gossip frame holds shut for several frames waiting on its greeting query (B292). +/// The poll stays for the other reason a single sample lies — a starved observer during a terrain +/// load — and prints how long the latch actually took. const GATE_TIMEOUT_SECS: f64 = 3.0; /// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`, diff --git a/crates/benilla-app/src/capture/probe_vendor_swap.rs b/crates/benilla-app/src/capture/probe_vendor_swap.rs new file mode 100644 index 00000000..36f759b7 --- /dev/null +++ b/crates/benilla-app/src/capture/probe_vendor_swap.rs @@ -0,0 +1,426 @@ +//! The **vendor-swap** live probe (`WOW_PROBE_VENDOR_SWAP=1`) — decision 2022's end-to-end +//! instrument: does a second vendor, opened while the first vendor's window is still up, swap the +//! WHOLE window — the stock `MerchantFrame_UpdateMerchantInfo`'s `UnitName("NPC")` title and its +//! `SetPortraitTexture(MerchantFramePortrait, "NPC")` face — on the very dispatch that raises +//! `MERCHANT_SHOW`; and does closing the window clear the `"npc"` token? +//! +//! The director's report (2026-09-05): the Goldshire inn's vendor window titled "Innkeeper +//! Farley" over Brog Hamfist's stock, with an empty portrait ring; then "clicking between vendors +//! while the window is open doesn't swap name and some other things maybe too". Both faces of +//! one seam — the decision has the mechanism. This probe stands in that inn, between its two +//! pure vendors (**Brog Hamfist** 151 and **Barkeep Dobbins** 465, 5.45 yd apart — live-DB +//! verified), so both are inside the service reach at once and the second open lands on top of +//! the first window exactly as the click does. +//! +//! Legs, one `PROBE_VENDOR_SWAP: PASS/FAIL/SKIP` line each: +//! 1. **open** — `CMSG_LIST_INVENTORY` to Brog; at the `MERCHANT_SHOW` dispatch, `UnitName("npc")` +//! and the title both read Brog's name. +//! 2. **portrait** — the round region is being sampled under the canonical `"npc"` key +//! (`portrait::BoothPanes`) and the booth publishes a live bake for it (`PortraitImages`). +//! 3. **swap** — with Brog's window still open, `CMSG_LIST_INVENTORY` to Dobbins; at that +//! `MERCHANT_SHOW`, both readings are Dobbins' name. +//! 4. **title** — a second later, the title the window is actually left showing is his too. +//! 5. **clear** — `HideUIPanel(MerchantFrame)` (the stock OnHide → `CloseMerchant`), and once the +//! session is gone `UnitExists("npc")` is nil. +//! +//! The event readings are taken **inside the event** by a Lua hook frame registered after the +//! stock window's, so a later `MERCHANT_UPDATE` repaint cannot mask what the open frame saw — +//! which is how the bug hid: a vendor whose items were already cached got no repaint, and one +//! whose items were still streaming got one a moment later that quietly corrected the title. +//! +//! Non-combat, nothing bought, nothing sold; the window is closed the way its own close button +//! closes it (no packet). +//! +//! ## The run recipe +//! ```text +//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \ +//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla +//! ``` +//! (the slot-keyed probe identity — method.md "The local vmangos server"). + +use bevy::prelude::*; + +use benilla_protocol::EntityKind; +use benilla_ui::script::UiScript; + +use super::probes::ProbeClock; +use crate::names::NameCache; +use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::player::Player; +use crate::portrait::{BoothPanes, PortraitImages, PortraitSource}; +use crate::ui_merchant::MerchantOpen; + +/// Brog Hamfist `` — `creature_template.entry = 151`, `npc_flags = 4`. +const BROG_ENTRY: u32 = 151; +/// Barkeep Dobbins — `entry = 465`, `npc_flags = 4`. +const DOBBINS_ENTRY: u32 = 465; +/// Midway between the two spawns (`creature.position_*`: Brog −9465.29, 9.63; Dobbins −9459.98, +/// 8.41; both z 57.15), so each is ~2.8 yd away — inside the 5.5556 yd service reach. +const STAND_AT: [f32; 3] = [-9462.6, 9.0, 57.15]; +const MAP: u32 = 0; + +/// Let the hop land and the inn stream before the first scan. +const SETTLE_SECS: f64 = 3.0; +/// How long to wait for both vendors (and their names) before calling the hop environmental. +const SCAN_TIMEOUT_SECS: f64 = 20.0; +/// How long a leg waits for its window / its event after the opcode goes out. +const WINDOW_TIMEOUT_SECS: f64 = 15.0; +/// How long the round portrait may take to be sampled and baked once the window is open. +const PORTRAIT_TIMEOUT_SECS: f64 = 10.0; +/// How long after the swap to read the title the window is actually left showing. +const SETTLED_SECS: f64 = 1.0; +/// The service reach the click cannot act past (`crate::target::SERVICE_RANGE_SQ`). +const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ; + +/// The hook frame: one line per merchant event, read INSIDE the dispatch. Registered after the +/// stock `MerchantFrame` (the probe installs it in-world, long after the FrameXML load), so the +/// stock `OnEvent` has already repainted by the time this runs and `MerchantNameText` shows what +/// the open frame left there. +const HOOK: &str = r#" + if not ProbeVendorSwapHooked then + ProbeVendorSwapHooked = true + ProbeVendorSwapLog = {} + local f = CreateFrame("Frame") + f:RegisterEvent("MERCHANT_SHOW") + f:RegisterEvent("MERCHANT_UPDATE") + f:RegisterEvent("MERCHANT_CLOSED") + f:SetScript("OnEvent", function() + table.insert(ProbeVendorSwapLog, + event .. "|" .. tostring(UnitName("npc")) .. "|" .. tostring(MerchantNameText:GetText())) + end) + end +"#; + +pub(crate) struct ProbeVendorSwapPlugin; + +impl Plugin for ProbeVendorSwapPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, vendor_swap_probe); + } +} + +#[derive(Resource, Default)] +struct VendorSwapProbe { + phase: Phase, + passes: u32, + fails: u32, + skips: u32, + brog: Option, + dobbins: Option, + exited: bool, +} + +#[derive(Default, Clone, Copy, PartialEq)] +enum Phase { + #[default] + Start, + /// `.go` sent; waiting for both vendors to stream in reach, with their names cached. + Settle { + sent_at: f64, + }, + /// Brog's `CMSG_LIST_INVENTORY` sent; waiting for his window and its `MERCHANT_SHOW`. + Open { + since: f64, + }, + /// Brog's window is open; waiting for the round portrait to be sampled and baked. + Portrait { + since: f64, + }, + /// Dobbins' `CMSG_LIST_INVENTORY` sent over Brog's open window; waiting for the swap. + Swap { + since: f64, + }, + /// The swap happened; reading the title the window settles on. + Settled { + since: f64, + }, + /// `HideUIPanel(MerchantFrame)` run; waiting for the session to clear, then one more poll. + Clear { + since: f64, + closed_at: Option, + }, + Done, +} + +/// The first `MERCHANT_SHOW` line in the hook's log, split into `(UnitName("npc"), title)`. +fn first_show(script: &UiScript) -> Option<(String, String)> { + let log: Vec = script.eval("return ProbeVendorSwapLog").unwrap_or_default(); + log.iter().find_map(|line| { + let mut parts = line.splitn(3, '|'); + match (parts.next(), parts.next(), parts.next()) { + (Some("MERCHANT_SHOW"), Some(npc), Some(title)) => { + Some((npc.to_string(), title.to_string())) + } + _ => None, + } + }) +} + +fn clear_log(script: &UiScript) { + if let Err(e) = script.run("ProbeVendorSwapLog = {}") { + error!("PROBE_VENDOR_SWAP: clearing the hook log: {e}"); + } +} + +#[allow(clippy::too_many_arguments)] +fn vendor_swap_probe( + time: ProbeClock, + mut probe: ResMut, + merchant: Res, + script: Option>, + mut names: ResMut, + panes: Res, + images: Res, + self_player: Query<(), With>, + player: Res, + units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without>, + net: Res, + mut exit: MessageWriter, +) { + if self_player.is_empty() { + return; // not in-world yet + } + let now = time.elapsed_secs_f64(); + let Some(script) = script else { + return; + }; + + match probe.phase { + Phase::Start => { + if let Err(e) = script.run(HOOK) { + error!("PROBE_VENDOR_SWAP: installing the merchant hook: {e}"); + probe.phase = Phase::Done; + return; + } + let [x, y, z] = STAND_AT; + info!("PROBE_VENDOR_SWAP: hopping to the Goldshire inn ({x}, {y}, {z}) map {MAP}"); + let _ = net.0.send(ClientCommand::Chat { + kind: ChatKind::Say, + target: None, + text: format!(".go xyz {x} {y} {z} {MAP}"), + }); + probe.phase = Phase::Settle { sent_at: now }; + } + Phase::Settle { sent_at } => { + if now - sent_at < SETTLE_SECS { + return; + } + let me = player.pos; + let find = |entry: u32| { + units + .iter() + .find(|(_, net_e, store, tf)| { + net_e.kind == EntityKind::Unit + && store.0.object_entry() == Some(entry) + && tf.translation.distance_squared(me) <= SCAN_RANGE_SQ + }) + .map(|(guid, ..)| guid.0) + }; + let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY)); + // Both names must be cached before the legs read them back out of the VM — the + // ask-once resolve is what the unit feed itself would send on the open frame. + let mut named = |g: Option| g.is_some_and(|g| names.resolve(g, &net).is_some()); + let (brog_named, dobbins_named) = (named(brog), named(dobbins)); + if brog_named && dobbins_named { + probe.brog = brog; + probe.dobbins = dobbins; + let brog = brog.expect("named"); + info!( + "PROBE_VENDOR_SWAP: Brog {brog:#x} and Dobbins {:#x} both in reach — \ + CMSG_LIST_INVENTORY to Brog", + dobbins.expect("named") + ); + clear_log(&script); + let _ = net.0.send(ClientCommand::ListInventory { guid: brog }); + probe.phase = Phase::Open { since: now }; + } else if now - sent_at > SCAN_TIMEOUT_SECS { + warn!( + "PROBE_VENDOR_SWAP: SKIP — vendors in reach: Brog {brog:?}, Dobbins \ + {dobbins:?} (names cached: {}/{}). Environmental (the `.go` refused, or the \ + inn never streamed).", + brog_named, dobbins_named + ); + probe.skips += 1; + probe.phase = Phase::Done; + } + } + Phase::Open { since } => { + let brog = probe.brog.expect("set at Settle"); + let expect = names.peek(brog).unwrap_or("").to_string(); + match (merchant.vendor == Some(brog), first_show(&script)) { + (true, Some((npc, title))) => { + if npc == expect && title == expect { + info!( + "PROBE_VENDOR_SWAP: open PASS — at MERCHANT_SHOW UnitName(\"npc\") \ + = {npc:?}, title = {title:?}" + ); + probe.passes += 1; + } else { + error!( + "PROBE_VENDOR_SWAP: open FAIL — at MERCHANT_SHOW UnitName(\"npc\") \ + = {npc:?}, title = {title:?}; expected {expect:?} for both" + ); + probe.fails += 1; + } + probe.phase = Phase::Portrait { since: now }; + } + _ if now - since > WINDOW_TIMEOUT_SECS => { + error!( + "PROBE_VENDOR_SWAP: open FAIL — no merchant window + MERCHANT_SHOW on \ + Brog within {WINDOW_TIMEOUT_SECS}s (vendor = {:?})", + merchant.vendor + ); + probe.fails += 1; + probe.phase = Phase::Done; + } + _ => {} + } + } + Phase::Portrait { since } => { + let sampled = panes.0.contains_key("npc"); + let baked = matches!(images.0.get("npc"), Some(PortraitSource::Live(_))); + if sampled && baked { + info!( + "PROBE_VENDOR_SWAP: portrait PASS — the round region samples the \"npc\" \ + slot and the booth publishes a live bake for it" + ); + probe.passes += 1; + } else if now - since > PORTRAIT_TIMEOUT_SECS { + let mut keys: Vec<&String> = panes.0.keys().collect(); + keys.sort(); + error!( + "PROBE_VENDOR_SWAP: portrait FAIL — sampled under \"npc\": {sampled}, live \ + bake: {baked}; pane keys published this frame: {keys:?}" + ); + probe.fails += 1; + } else { + return; + } + let dobbins = probe.dobbins.expect("set at Settle"); + info!("PROBE_VENDOR_SWAP: CMSG_LIST_INVENTORY to Dobbins over Brog's open window"); + clear_log(&script); + let _ = net.0.send(ClientCommand::ListInventory { guid: dobbins }); + probe.phase = Phase::Swap { since: now }; + } + Phase::Swap { since } => { + let dobbins = probe.dobbins.expect("set at Settle"); + let expect = names.peek(dobbins).unwrap_or("").to_string(); + match (merchant.vendor == Some(dobbins), first_show(&script)) { + (true, Some((npc, title))) => { + if npc == expect && title == expect { + info!( + "PROBE_VENDOR_SWAP: swap PASS — at MERCHANT_SHOW UnitName(\"npc\") \ + = {npc:?}, title = {title:?}" + ); + probe.passes += 1; + } else { + error!( + "PROBE_VENDOR_SWAP: swap FAIL — at MERCHANT_SHOW UnitName(\"npc\") \ + = {npc:?}, title = {title:?}; expected {expect:?} for both" + ); + probe.fails += 1; + } + probe.phase = Phase::Settled { since: now }; + } + _ if now - since > WINDOW_TIMEOUT_SECS => { + error!( + "PROBE_VENDOR_SWAP: swap FAIL — no window swap + MERCHANT_SHOW on \ + Dobbins within {WINDOW_TIMEOUT_SECS}s (vendor = {:?})", + merchant.vendor + ); + probe.fails += 1; + probe.phase = Phase::Done; + } + _ => {} + } + } + Phase::Settled { since } => { + if now - since < SETTLED_SECS { + return; + } + let dobbins = probe.dobbins.expect("set at Settle"); + let expect = names.peek(dobbins).unwrap_or("").to_string(); + let title: String = script + .eval("return tostring(MerchantNameText:GetText())") + .unwrap_or_default(); + if title == expect { + info!("PROBE_VENDOR_SWAP: title PASS — the window settled on {title:?}"); + probe.passes += 1; + } else { + error!( + "PROBE_VENDOR_SWAP: title FAIL — the window settled on {title:?}, expected \ + {expect:?}" + ); + probe.fails += 1; + } + if let Err(e) = script.run("HideUIPanel(MerchantFrame)") { + error!("PROBE_VENDOR_SWAP: HideUIPanel(MerchantFrame): {e}"); + } + probe.phase = Phase::Clear { + since: now, + closed_at: None, + }; + } + Phase::Clear { since, closed_at } => { + match closed_at { + None if merchant.vendor.is_none() => { + probe.phase = Phase::Clear { + since, + closed_at: Some(now), + }; + } + None if now - since > WINDOW_TIMEOUT_SECS => { + error!( + "PROBE_VENDOR_SWAP: clear FAIL — the session never cleared after \ + HideUIPanel (vendor = {:?})", + merchant.vendor + ); + probe.fails += 1; + probe.phase = Phase::Done; + } + None => {} + // One more poll after the clear: the unit feed pushes the token's absence on the + // frame after the session clears. + Some(at) if now - at >= 0.25 => { + // `and 1 or 0`: the binding answers a Lua boolean where the reference + // answers `1`/nil (its own noted question), and a `false ~= nil` is true. + let exists: i64 = script + .eval(r#"return UnitExists("npc") and 1 or 0"#) + .unwrap_or(1); + if exists == 1 { + error!( + "PROBE_VENDOR_SWAP: clear FAIL — UnitExists(\"npc\") is still true \ + after the window closed" + ); + probe.fails += 1; + } else { + info!("PROBE_VENDOR_SWAP: clear PASS — UnitExists(\"npc\") is nil"); + probe.passes += 1; + } + probe.phase = Phase::Done; + } + Some(_) => {} + } + } + Phase::Done => { + if probe.exited { + return; + } + probe.exited = true; + info!( + "PROBE_VENDOR_SWAP: DONE pass={} fail={} skip={}", + probe.passes, probe.fails, probe.skips + ); + // The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit + // plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie + // client holding the probe account. + exit.write(AppExit::Success); + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(5)); + warn!("PROBE_VENDOR_SWAP: still alive 5s after AppExit — hard exit"); + std::process::exit(0); + }); + } + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index a8a25deb..687f6ebb 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -120,6 +120,7 @@ impl Plugin for DevProbesPlugin { "WOW_PROBE_PITCH", "WOW_PROBE_CHEST", "WOW_PROBE_CLAM", + "WOW_PROBE_VENDOR_SWAP", "WOW_RIG", "WOW_LIVE_FPS", // A screenshot burst and a pick burst are wall-clock schedules too, and on an @@ -341,6 +342,13 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_PROBE_SERVICE").is_ok() { app.add_plugins(crate::capture::ProbeServicePlugin); } + // The vendor-swap live probe: `WOW_PROBE_VENDOR_SWAP=1` stands between the Goldshire + // inn's two vendors, opens one over the other's window, and reads the `"npc"` token, + // the title and the round portrait at the `MERCHANT_SHOW` dispatch itself — decision + // 2022's instrument (see `capture::ProbeVendorSwapPlugin`). + if std::env::var("WOW_PROBE_VENDOR_SWAP").is_ok() { + app.add_plugins(crate::capture::ProbeVendorSwapPlugin); + } // The GM trouble-ticket live probe: `WOW_PROBE_GMTICKET=1` drives the whole five-opcode // ticket wire through the live VM's own bindings — queue status, clean slate, file, edit, // abandon — and prints the row the server must have stored so the operator can check the diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 9722bcd3..4c694111 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -30,7 +30,8 @@ mod apply; pub(crate) mod io; mod motion; -use apply::{apply_net_updates, tag_self_player}; +pub(crate) use apply::apply_net_updates; +use apply::tag_self_player; // The per-frame motion model lives in [`motion`]: `RemoteMotion`/`Spline` are re-exported for the // crate (the animation selector reads them); the integration systems + pose helpers stay `pub(super)` @@ -151,12 +152,13 @@ impl Plugin for NetPlugin { drive_display_facing, ) .chain() - .in_set(WorldStage::Net) - // `drive_display_facing` reads `InteractNpc`; ordering the chain after its - // writer keeps the read deterministic rather than schedule-order-dependent. - // The cost is that the writer sees last frame's window state, which cannot - // matter: a window is open for seconds and the ease takes ~8 frames. - .after(crate::ui_session::feed_interact_npc), + .in_set(WorldStage::Net), + // `InteractNpc`'s writer (`ui_session::feed_interact_npc`) seats itself INSIDE + // this chain — after `apply_net_updates`, before `drive_display_facing` — and + // declares both edges itself. It used to sit ahead of the whole chain, which + // read the window state one frame late; harmless for an ~8-frame facing ease, + // and exactly the frame the `"npc"` unit token was stale on when a window's + // own `MERCHANT_SHOW` handler read it (decision 2022). ) // Not part of the movement chain above: one send on the world-enter message. .add_systems(Update, send_query_time.in_set(WorldStage::Net)) @@ -330,7 +332,7 @@ pub(crate) struct ActiveMover; /// The inbound event channel — drained each frame by [`apply_net_updates`]. #[derive(Resource)] -struct NetEvents(Receiver); +pub(crate) struct NetEvents(Receiver); /// The outbound command channel — cloned by the player/chat systems to send movement + chat. #[derive(Resource)] diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 91b3830d..562f88f7 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -92,7 +92,7 @@ fn addressed_store<'a>( // the 5-element ResMut tuples as "very complex types", but a named alias per tuple would be less // legible than the inline, commented groups. #[allow(clippy::too_many_arguments, clippy::type_complexity)] -pub(super) fn apply_net_updates( +pub(crate) fn apply_net_updates( mut commands: Commands, events: Res, mut index: ResMut, diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index c53da175..6ea7df0e 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -638,6 +638,9 @@ pub(in crate::net) fn extrapolate_remote_units( vel, time.delta(), crate::player::mover::Support { + // A watched player is a player body: `0x5fa550` is TRUE for an + // uncharmed player object, so its `H` is the player's own 1.0. + rise: crate::player::STEP_UP_HEIGHT, offset: if modes & move_flags::HOVER != 0 { crate::player::HOVER_HEIGHT } else { diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 3d67816d..9c5a4359 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -607,16 +607,21 @@ pub(crate) fn ground_derived( } } -/// Distance (yd) above a creature's current feet that the terrain probe starts. Generous enough to -/// clear the small float (a slightly-high server Z) and the "little hill" a straight-line spline Z -/// leaves a unit inside, low enough not to grab an overhang/bridge a unit walks *under*. It also -/// **self-gates a genuinely-airborne unit**: a creature hovering/flying farther than this above the -/// ground leaves the surface out of the probe's reach, so it is never clamped. (A full swept -/// controller — the real client's mechanism, which can't tunnel into rising ground at all — is the -/// follow-up for large deviations; decision 0059.) -const GROUND_CLAMP_UP: f32 = 2.5; -/// Distance (yd) below the origin the probe additionally reaches, so a unit follows a small step/slope -/// *down* onto the surface. Total cast length is `GROUND_CLAMP_UP + GROUND_CLAMP_DOWN`. +/// How far **above the seat** an idle unit's settle probe starts (yd): the reference's own +/// backface band. The swept-prism TOI (`0x632830`, wow-re `resolve_clip.rs::polygon_toi`) still +/// counts a face the prism has already passed as a hit at `t = 0` when it is within `1/36` yd +/// (`[0x7ff9c8]`) behind the probe, so a floor a hair above the feet supports the body; anything +/// further above is simply not there. **Not** a lift: this clamp once started 2.5 yd above the seat +/// (0059's "clear the little hill"), which is a probe the reference never casts — from up there the +/// lid of a cage a unit stands *inside* reads as its floor (B372, decision 2018). The hill is the +/// walker's business now (the swept step below), and an idle unit is grounded exactly as far as the +/// reference's settle would ground it: downward, from where it stands. +const IDLE_UP_BAND: f32 = 1.0 / 36.0; +/// Distance (yd) below the seat the settle probe reaches, so a unit follows a small step/slope +/// *down* onto the surface — the reference's settle is `d·1.849 + 1/36` and a body it does not +/// reach *falls*; a stand-in for the fall, since an idle unit here has no arc to run. A **miss** +/// leaves the unit at its seat, which is where a genuinely airborne one belongs and doubles as the +/// airborne gate (a hovering/flying unit farther than this above the ground is never clamped). const GROUND_CLAMP_DOWN: f32 = 4.0; /// **The Y a grounded mover ends its frame at** — the reference's `0x634040` walk-resolve outcome @@ -654,14 +659,24 @@ pub(crate) fn grounded_y( y } -/// Snap every **grounded creature** onto benilla's own terrain — the path-walkers (a ground spline) +/// Ground every **grounded creature** on benilla's own world — the path-walkers (a ground spline) /// **and the idle ones standing at their raw spawn Z** (the "NPCs floating a bit"). The real client -/// doesn't trust the wire Z for a ground unit: a walker re-derives Z from the surface (byte-verified — -/// the grounded fork zeroes the spline Z-delta and the WALK resolver reads Z off the world trace), and -/// an idle unit reads grounded against the reference too (the exact idle path isn't byte-pinned yet — -/// decision 0059). We mirror the *behaviour*: cast a ray straight down against the terrain/WMO -/// **walking** colliders — the same set the player stands on ([`benilla_world::collision::WorldCollision::body_filter`]) — and set the -/// unit's Y to the hit. +/// doesn't trust the wire Z for a walking unit: the grounded fork zeroes the spline Z-delta and the +/// WALK resolver reads Z off the world trace (byte-verified, decision 0059); an idle unit reads +/// grounded against the reference too (the exact idle path isn't byte-pinned yet — 0059's open +/// follow-up, dispatched again under decision 2018). +/// +/// **The probe geometry is the reference's, and it starts at the body** (decision 2018). A walker +/// continuing its path runs the shared swept step ([`crate::player::mover::grounded_step`]) from +/// where it stood last frame with Δz = 0 — the ride up a walkable rise, the atomic step-up with a +/// creature's own rise budget, the election snap down — and takes only the Y; the spline keeps the +/// xy. An idle unit, and a walker on its first frame of a new path, casts a one-sided ray **down +/// from its seat** (with the reference's `1/36` band above it) against the terrain/WMO **walking** +/// colliders — the same set the player stands on +/// ([`benilla_world::collision::WorldCollision::body_filter`]) — and takes the hit. Nothing here +/// ever probes from *above* the body: that was 0059's `seat + 2.5` origin, and from up there a +/// unit the server stood inside a GameObject's collision box found the box's lid as its floor +/// (Galen Goodward on his cage, B372). /// /// Scope is [`ground_derived`]: every `Unit`, plus a **`Player` the server moves** — a Playerbot, /// or anyone under a Charge/knockback/fear — because a body the server splines has no other Z @@ -693,6 +708,11 @@ pub(in crate::net) fn ground_clamp_creatures( // (decision 1780). points: benilla_world::world_point::WorldPoint, epoch: Res, + // The body a walker's step sweeps — the one player capsule every body shares, as the remote + // dead-reckon does (decision 0626). The reference sweeps each unit's own `CreatureModelData` + // radius/height (`[CMovement+0xb0]`/`+0xb4`, decision 1125); per-creature extents are the + // refinement, not this record's. + capsule: Res, mut commands: Commands, mut q: Query<( Entity, @@ -714,8 +734,8 @@ pub(in crate::net) fn ground_clamp_creatures( let cost = clamp_cost_enabled(); let legacy = clamp_seat_disabled(); let t0 = cost.then(std::time::Instant::now); - let (mut visited, mut skipped, mut held, mut cast, mut hit_n, mut moved) = - (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + let (mut visited, mut skipped, mut held, mut cast, mut swept, mut hit_n, mut moved) = + (0u32, 0u32, 0u32, 0u32, 0u32, 0u32, 0u32); // What re-armed each cast (1384): the unit's own seat moved, or the world's colliders changed // under a unit that didn't. The second must be ~0 in a settled scene — a nonzero steady-state // `armed` means the collider set is churning and the gate is holding nothing. @@ -797,34 +817,144 @@ pub(in crate::net) fn ground_clamp_creatures( } } } - let origin = Vec3::new(t.translation.x, seat_y + GROUND_CLAMP_UP, t.translation.z); - let reach = GROUND_CLAMP_UP + GROUND_CLAMP_DOWN; - cast += 1; - // The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a - // face whose winding points away is no floor, or an idle NPC would stand mid-air on the - // very shell face the player mover now falls through. - let hit = world.ray_body(origin, Dir3::NEG_Y, reach); - let floor = hit.as_ref().map(|h| { - hit_n += 1; - origin.y - h.distance - }); // The water-walker's floor, and **only** for a creature the swim mark already let through: // the reference takes this arm only when the swim bit is clear (wow-re - // `moveflag-family.md` §2.2), and a swimming creature `continue`d above. + // `moveflag-family.md` §2.2), and a swimming creature `continue`d above. The plane is the + // liquid surface in Bevy Y, which both answers below read the same way. // // The reference's second, rate-limited hover pass (`0x636fa1`, climbing back toward the - // clearance at 7 yd/s) is deliberately NOT reproduced in [`grounded_y`]: this clamp is a - // pure function of (server pose, colliders) by construction, which is what makes its cache - // gate sound (decision 1384), and a per-frame ramp is state. A creature's seat is the - // server's, so the static answer is the one it converges to anyway. - let water = granted.water_walking().then(|| { - let wow = bevy_to_wow(t.translation); - let who = benilla_world::world_point::Subject::Unit(entity); - points - .liquid_at(who, wow) - .map(|l| t.translation.y + (l.surface_z - wow[2])) - }); - let y = grounded_y(seat_y, floor, water.flatten(), granted.hovering()); + // clearance at 7 yd/s) is deliberately NOT reproduced: this clamp is a pure function of + // (server pose, colliders) by construction, which is what makes its cache gate sound + // (decision 1384), and a per-frame ramp is state. A creature's seat is the server's, so the + // static answer is the one it converges to anyway. + let water = granted + .water_walking() + .then(|| { + let wow = bevy_to_wow(t.translation); + let who = benilla_world::world_point::Subject::Unit(entity); + points + .liquid_at(who, wow) + .map(|l| t.translation.y + (l.surface_z - wow[2])) + }) + .flatten(); + let hover = granted.hovering(); + // ── Two answers, one probe law (decision 2018). ── + // The reference has exactly one vertical probe geometry and it starts AT THE BODY: the WALK + // resolver's settle sweeps DOWN from the current position (`0x636dcd`–`0x636e45`, `d·1.849 + // + 1/36`), and the only way up is the multipass step-up, which runs after a blocking hit + // on the horizontal leg and rises at most `H`. Nothing ever probes from above the body — + // and that is what this clamp did (`seat + 2.5`), so a unit the server stood INSIDE a + // GameObject's collision box had its ray start above the box's lid, find the lid as a + // floor, and stand on it (Galen Goodward on his cage, B372: seat 22.42, ray from 24.92, lid + // at 24.67). + // + // - A **walker** continuing a path runs the reference's own step: Δz = 0 from where it + // stood last frame, then the shared swept resolve ([`grounded_step`] — the ride, the + // step-up with a creature's own `H` of 2.0, the election snap), and only Y is taken; the + // spline keeps the xy. This is what keeps a chord under a hill ON the hill (0059's + // "creatures run through small hills"): the surface rises under the body a frame at a + // time and the sweep rides it up, as the reference does, instead of a probe from above + // finding the hilltop. Over a hollow the snap misses and the step descends by its own + // reach a frame — the fall's stand-in — until the floor is under it again. + // - An **idle** unit, and a walker's first frame on a new path (the reference re-bases its + // mover on every inbound packet), is the settle alone: DOWN from the seat, with the + // reference's own `1/36` band above it. Memoryless, from the seat: decision 1384's law. + let path = spline.map(|s| (s.id, s.start)); + let continuing = clamped + .as_deref() + .filter(|c| path.is_some() && c.path == path) + .map(|c| (c.xz, c.y_written)); + cast += 1; + let (y, hit_ground) = if let Some((pxz, py)) = continuing { + swept += 1; + let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5); + let from = Vec3::new(pxz[0], py, pxz[1]) + half_h; + // The frame's horizontal displacement as a velocity over one second: `grounded_step` + // takes `speed · dt` as its travel, and the reference's walk step is likewise a + // horizontal distance and a direction (`0x6367b0`'s own signature). + let delta = Vec3::new(xz[0] - pxz[0], 0.0, xz[1] - pxz[1]); + let g = crate::player::mover::grounded_step( + &world, + &capsule.0, + from, + delta, + Duration::from_secs(1), + crate::player::mover::Support { + rise: crate::player::CREATURE_STEP_UP_HEIGHT, + offset: if hover { + crate::player::HOVER_HEIGHT + } else { + 0.0 + }, + water, + // Per-frame state the memo does not keep (1129): a walker following a steep + // face down gets the ordinary cone reach, and its path's next sample corrects + // whatever that misses. + steep: false, + }, + ); + let y = g.center.y - half_h.y; + if benilla_assets::trace::enabled_for("clmp") + && clamp_trace_display().is_some_and(|d| net.display_id == Some(d)) + { + let z_of = |y: f32| bevy_to_wow(Vec3::new(0.0, y, 0.0))[2]; + benilla_assets::trace::line( + "clmp", + &format!( + "display={} walk from_z={:.3} seat_z={:.3} d={:.3} ground={} z={:.3}", + net.display_id.unwrap_or(0), + z_of(py), + z_of(seat_y), + delta.length(), + g.ground.is_some() as u8, + z_of(y), + ), + ); + } + (y, g.ground.is_some()) + } else { + let origin = Vec3::new(t.translation.x, seat_y + IDLE_UP_BAND, t.translation.z); + let reach = IDLE_UP_BAND + GROUND_CLAMP_DOWN; + // The one-sided down-ray (decision 0970): a creature grounds like the player grounds + // — a face whose winding points away is no floor, or an idle NPC would stand mid-air + // on the very shell face the player mover now falls through. + let hit = world.ray_body(origin, Dir3::NEG_Y, reach); + let floor = hit.as_ref().map(|h| origin.y - h.distance); + let y = grounded_y(seat_y, floor, water, hover); + // The clamp trace (`WOW_MOVE_TRACE`, tag `clmp`, filtered to one display by + // `WOW_CLAMP_TRACE=`): one line per cast for that display — the seat the + // probe measured from, where the ray started, what it hit and the Z it wrote, in WoW + // coordinates. B372's reading instrument: "the NPC stands on the cage" is a claim + // about which surface this ray found, and no screenshot can say whether that was + // the lid or the floor. + if benilla_assets::trace::enabled_for("clmp") + && clamp_trace_display().is_some_and(|d| net.display_id == Some(d)) + { + let seat = bevy_to_wow(Vec3::new(t.translation.x, seat_y, t.translation.z)); + let z_of = |y: f32| bevy_to_wow(Vec3::new(0.0, y, 0.0))[2]; + let hit_s = hit.as_ref().map_or("miss".to_string(), |h| { + format!("hit d={:.3} n.y={:+.3}", h.distance, h.normal.y) + }); + benilla_assets::trace::line( + "clmp", + &format!( + "display={} idle seat=({:.2},{:.2},{:.3}) origin_z={:.3} reach={:.2} {hit_s} floor_z={} z={:.3}", + net.display_id.unwrap_or(0), + seat[0], + seat[1], + seat[2], + z_of(origin.y), + reach, + floor.map_or("-".to_string(), |f| format!("{:.3}", z_of(f))), + z_of(y), + ), + ); + } + (y, hit.is_some()) + }; + if hit_ground { + hit_n += 1; + } // Exact bit equality is deliberate, not a sloppy float compare: the question is "would the // write change anything" — Bevy's change detection fires on the DerefMut regardless of // value, so writing an equal Y every frame marked every standing creature's transform @@ -838,9 +968,10 @@ pub(in crate::net) fn ground_clamp_creatures( xz, seat_y, y_written: t.translation.y, - hit: hit.is_some(), + hit: hit_ground, epoch: epoch.get(), modes: granted, + path, }; match clamped.as_deref_mut() { Some(c) => *c = state, @@ -867,12 +998,20 @@ pub(in crate::net) fn ground_clamp_creatures( // Per 0734's law (~10.5 ns per row visit), the walk itself is never the cost here: at ~800 // units it is ~8 µs. Only `ms` justifies the slice — quote it, not the counts. eprintln!( - "[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} reseat={reseat} armed={armed} hit={hit_n} moved={moved} ms={:.3}", + "[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} swept={swept} reseat={reseat} armed={armed} hit={hit_n} moved={moved} ms={:.3}", t0.elapsed().as_secs_f32() * 1000.0 ); } } +/// The one display the `clmp` trace follows (`WOW_CLAMP_TRACE=`); `None` = the tag +/// writes nothing. A filter rather than a firehose: every idle unit in a city re-casts on each +/// collider epoch, and one unit's line per cast is what a report reads. +fn clamp_trace_display() -> Option { + static ID: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ID.get_or_init(|| std::env::var("WOW_CLAMP_TRACE").ok()?.trim().parse().ok()) +} + /// Whether the ground-clamp meter is armed (`WOW_CLAMP_COST`). Read once, then a relaxed bool. fn clamp_cost_enabled() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -1025,6 +1164,13 @@ pub(crate) struct GroundClamped { /// both change what the same ray, from the same seat, in the same world, resolves to — so they /// are as much an input to the cached answer as the other three. modes: crate::net::UnitMoveModes, + /// **The path this answer continues** — a walker's `(spline id, start)` — or `None` for an idle + /// answer. A walker's frame is the reference's: Δz = 0 from where it stood last frame, then the + /// swept resolve (decision 2018). That continuity is exactly as long as one server path: the + /// reference re-bases its mover on every inbound movement packet (`0x7c6420`, `base := + /// packet pos`), so a new spline starts again from the server's own Z, and the late-floor + /// ratchet 1384 removed cannot outlive a path here either. + path: Option<(u32, Instant)>, } /// The reference's `0x6030c0` decision for one creature, as a pure function of everything it @@ -1180,6 +1326,11 @@ mod under_floor { // surface (decision 1780). Seeded empty: this harness is about geometry, and an empty // world answers "no liquid here", which is the case every test below is written for. benilla_world::world_point::init_world_point_resources(app.world_mut()); + // The body a walker's swept step sweeps (decision 2018) — idle units never touch it. + app.insert_resource(crate::player::PlayerCapsule(Collider::capsule( + crate::player::CAPSULE_RADIUS, + crate::player::CAPSULE_HEIGHT - 2.0 * crate::player::CAPSULE_RADIUS, + ))); // `update()` never runs plugin `finish()`, where avian seats its diagnostics resources — // and the second `update()` below (the one that lands the late floor) does step physics. app.finish(); @@ -1361,7 +1512,7 @@ mod under_floor { #[test] fn a_unit_with_no_ground_in_reach_sits_where_the_server_put_it() { // The miss branch: an airborne/unstreamed unit is left at its seat, never at whatever the - // clamp last wrote. `GROUND_CLAMP_UP + GROUND_CLAMP_DOWN` below the seat is empty air here. + // clamp last wrote. The whole `GROUND_CLAMP_DOWN` reach below the seat is empty air here. let (mut app, npc) = half_arrived_world(); app.world_mut() .get_mut::(npc) @@ -1724,6 +1875,11 @@ mod server_moved_players { )); app.init_asset::().init_resource::(); benilla_world::world_point::init_world_point_resources(app.world_mut()); + // The body a walker's swept step sweeps (decision 2018) — idle units never touch it. + app.insert_resource(crate::player::PlayerCapsule(Collider::capsule( + crate::player::CAPSULE_RADIUS, + crate::player::CAPSULE_HEIGHT - 2.0 * crate::player::CAPSULE_RADIUS, + ))); app.finish(); app.cleanup(); // rim ─╮ ╭─ rim @@ -2017,3 +2173,370 @@ mod server_moved_players { } } } + +/// **B372, and the probe law behind it** (decision 2018): a unit the server stands INSIDE a +/// GameObject's collision box — Galen Goodward in his cage, to the yard — and what a probe that +/// starts at the body finds there, idle and walking; then the walker's two other duties, in the +/// same harness: riding a hill it would otherwise chord under, and re-basing on a new path. +/// +/// The box is wound outward like every M2 collision hull (`G_Cage.mdx` is one such box, 8 vertices +/// / 12 triangles), so from inside every face is a backface: a down-ray passes the box's floor to the +/// terrain, an up-probe passes its lid. The one thing that can ever put a body on the lid is a ray +/// that STARTS above it — which is exactly what the clamp did before this record. +#[cfg(test)] +mod inside_a_hull { + use std::time::{Duration, Instant}; + + use avian3d::prelude::*; + use benilla_protocol::EntityKind; + use bevy::ecs::system::RunSystemOnce; + use bevy::prelude::*; + + use super::{ground_clamp_creatures, GroundClamped, Spline}; + use crate::net::NetEntity; + use benilla_world::collision::ColliderEpoch; + + /// Galen's cage, relative to the terrain under it: the collision box's floor 0.4 yd up, its + /// lid 2.72 yd up (`G_Cage.mdx`: z 0.398..2.724 at size 1), and his seat 0.07 above the box + /// floor (the deployed spawn: 22.4164 against a box floor at 22.34). + const TERRAIN_Y: f32 = 22.0; + const BOX_FLOOR_Y: f32 = TERRAIN_Y + 0.4; + const LID_Y: f32 = TERRAIN_Y + 2.72; + const SEAT_Y: f32 = BOX_FLOOR_Y + 0.07; + const HALF: f32 = 1.43; + + /// A 30×30 up-wound quad at `y`, around the origin — or, for the shipped-placement test, + /// around the cage's own footprint. + fn floor_at(app: &mut App, y: f32) -> Entity { + floor_around(app, Vec3::new(0.0, y, 0.0)) + } + + fn floor_around(app: &mut App, c: Vec3) -> Entity { + let (x, y, z) = (c.x, c.y, c.z); + let verts = vec![ + Vec3::new(x - 15.0, y, z - 15.0), + Vec3::new(x + 15.0, y, z - 15.0), + Vec3::new(x + 15.0, y, z + 15.0), + Vec3::new(x - 15.0, y, z + 15.0), + ]; + app.world_mut() + .spawn(( + RigidBody::Static, + Collider::trimesh(verts, vec![[0u32, 2, 1], [0, 3, 2]]), + Transform::default(), + )) + .id() + } + + /// A closed box hull wound **outward** on every face — checked, not assumed: each triangle's + /// winding normal is flipped to point away from the box's centre, because under the one-sided + /// law a face wound inward is a hole, and a fixture with a hole would pass this module for + /// the wrong reason. + fn box_hull(app: &mut App, min: Vec3, max: Vec3) -> Entity { + let c = |i: usize| { + Vec3::new( + if i & 1 == 0 { min.x } else { max.x }, + if i & 2 == 0 { min.y } else { max.y }, + if i & 4 == 0 { min.z } else { max.z }, + ) + }; + let verts: Vec = (0..8).map(c).collect(); + let centre = (min + max) * 0.5; + // Each face as its four corner indices; the diagonal split below, then the outward flip. + const FACES: [[u32; 4]; 6] = [ + [0, 1, 3, 2], // y = min + [4, 5, 7, 6], // y = max + [0, 1, 5, 4], // z = min + [2, 3, 7, 6], // z = max + [0, 2, 6, 4], // x = min + [1, 3, 7, 5], // x = max + ]; + let mut tris = Vec::new(); + for f in FACES { + for [a, b, d] in [[f[0], f[1], f[2]], [f[0], f[2], f[3]]] { + let (va, vb, vd) = (verts[a as usize], verts[b as usize], verts[d as usize]); + let n = (vb - va).cross(vd - va); + let outward = n.dot((va + vb + vd) / 3.0 - centre) > 0.0; + tris.push(if outward { [a, b, d] } else { [a, d, b] }); + } + } + app.world_mut() + .spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform::default(), + )) + .id() + } + + fn world() -> App { + let mut app = App::new(); + app.init_resource::(); + app.add_plugins(( + MinimalPlugins, + bevy::transform::TransformPlugin, + bevy::asset::AssetPlugin::default(), + bevy::scene::ScenePlugin, + PhysicsPlugins::new(bevy::app::PostUpdate), + )); + app.init_asset::().init_resource::(); + benilla_world::world_point::init_world_point_resources(app.world_mut()); + // The walker's swept step needs the body it sweeps, exactly as the remote dead-reckon does. + app.insert_resource(crate::player::PlayerCapsule(Collider::capsule( + crate::player::CAPSULE_RADIUS, + crate::player::CAPSULE_HEIGHT - 2.0 * crate::player::CAPSULE_RADIUS, + ))); + app.finish(); + app.cleanup(); + app + } + + /// The cage on its terrain, and a unit spawned at Galen's seat inside it. + fn caged() -> (App, Entity) { + let mut app = world(); + floor_at(&mut app, TERRAIN_Y); + box_hull( + &mut app, + Vec3::new(-HALF, BOX_FLOOR_Y, -HALF), + Vec3::new(HALF, LID_Y, HALF), + ); + let npc = spawn_unit(&mut app, Vec3::new(0.0, SEAT_Y, 0.0)); + app.update(); + (app, npc) + } + + fn spawn_unit(app: &mut App, at: Vec3) -> Entity { + app.world_mut() + .spawn(( + NetEntity { + kind: EntityKind::Unit, + display_id: None, + scale: 1.0, + }, + Transform::from_translation(at), + )) + .id() + } + + fn clamp(app: &mut App) { + app.world_mut() + .run_system_once(ground_clamp_creatures) + .expect("run the clamp"); + } + + fn y_of(app: &App, e: Entity) -> f32 { + app.world().get::(e).unwrap().translation.y + } + + /// A grounded path from `a` to `b` over `secs`, installed on `e` (the sampler is not run here; + /// the tests move the transform themselves, one "frame" at a time, which is what the clamp + /// sees: a fresh sample's xz, with the chord's Z as its seat). + fn path(app: &mut App, e: Entity, id: u32, a: [f32; 3], b: [f32; 3], secs: u64) { + app.world_mut().entity_mut(e).insert(Spline { + deck: None, + points: vec![a, b], + start: Instant::now(), + duration: Duration::from_secs(secs), + id, + grounded: true, + run_mode: true, + }); + } + + /// **B372.** Idle at the server's seat inside the box, the unit stands anywhere but on the + /// lid: from the seat, the box's own floor is a backface for a down-ray and the lid is above + /// and behind, so the ray finds the terrain — never a surface 2.2 yd over its head. + #[test] + fn an_idle_unit_inside_a_closed_hull_is_never_put_on_its_lid() { + let (mut app, npc) = caged(); + clamp(&mut app); + let y = y_of(&app, npc); + assert!( + y < BOX_FLOOR_Y + 0.1, + "inside the box, not on it: got {y}, the lid is at {LID_Y}" + ); + assert!( + (y - TERRAIN_Y).abs() < 1e-3, + "a down-ray from the seat passes the box's backface floor to the terrain: got {y}" + ); + assert_eq!( + app.world().get::(npc).unwrap().seat_y, + SEAT_Y, + "the seat stays the server's, never the answer just written" + ); + } + + /// The same body walking inside the cage: the swept step from where it stood, Δz = 0, meets + /// only backfaces — the walls from inside, the lid from below — so it walks its floor and is + /// never lifted onto the lid. + #[test] + fn a_walker_inside_a_closed_hull_keeps_its_floor() { + let (mut app, npc) = caged(); + clamp(&mut app); // the first frame: from the seat + let start = y_of(&app, npc); + path(&mut app, npc, 1, [0.0, 0.0, SEAT_Y], [0.0, 1.0, SEAT_Y], 2); + // Twelve "frames" of a slow walk across the box, the chord at the seat's height. + for i in 1..=12 { + let z = -0.6 + i as f32 * 0.1; + let mut t = app.world_mut().get_mut::(npc).unwrap(); + t.translation = Vec3::new(0.0, SEAT_Y, z); + clamp(&mut app); + let y = y_of(&app, npc); + assert!( + y < BOX_FLOOR_Y + 0.1, + "frame {i}: still inside the box, got {y} (lid {LID_Y})" + ); + assert!( + (y - start).abs() < 0.05, + "frame {i}: walking level ground keeps its height, got {y} from {start}" + ); + } + } + + /// **0059's hill, kept.** A server chord between two hilltop waypoints cuts through the hill; + /// a walker riding it frame by frame stays ON the hill because each frame's swept step starts + /// from the surface it stood on and rides the rise — the reference's mechanism, which needs no + /// probe from above. + #[test] + fn a_walker_rides_a_hill_its_chord_cuts_under() { + let mut app = world(); + // A 3-yd hill of 30° over ±5 yd, then flat: the profile in x, extruded across z. + let profile: [(f32, f32); 5] = [ + (-20.0, 0.0), + (-5.0, 0.0), + (0.0, 2.9), + (5.0, 0.0), + (20.0, 0.0), + ]; + let mut verts = Vec::new(); + let mut tris = Vec::new(); + for (i, &(x, y)) in profile.iter().enumerate() { + verts.push(Vec3::new(x, y, -10.0)); + verts.push(Vec3::new(x, y, 10.0)); + if i > 0 { + let b = (i as u32 - 1) * 2; + tris.push([b, b + 1, b + 3]); + tris.push([b, b + 3, b + 2]); + } + } + app.world_mut().spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform::default(), + )); + let npc = spawn_unit(&mut app, Vec3::new(-6.0, 0.0, 0.0)); + app.update(); + clamp(&mut app); + // The chord runs flat at y = 0 from x = −6 to +6: under the hill the whole way. + path(&mut app, npc, 7, [-6.0, 0.0, 0.0], [6.0, 0.0, 0.0], 4); + let mut peak = f32::MIN; + for i in 1..=120 { + let x = -6.0 + i as f32 * 0.1; + let mut t = app.world_mut().get_mut::(npc).unwrap(); + t.translation = Vec3::new(x, 0.0, 0.0); + clamp(&mut app); + let y = y_of(&app, npc); + let surface = if x.abs() < 5.0 { + 2.9 * (1.0 - x.abs() / 5.0) + } else { + 0.0 + }; + assert!( + y >= surface - 0.05, + "frame {i} at x={x:.1}: under the hill ({y} vs surface {surface:.2})" + ); + peak = peak.max(y); + } + assert!(peak > 2.5, "the walker climbed the hill: peak {peak}"); + } + + /// **B372 at the shipped mesh.** `G_Cage.mdx`'s real hull at the deployed placement (guid + /// 29361: (−9898.3, −3724.76, 21.9428), `GAMEOBJECT_ROTATION` `(0, 0, 0.909961, 0.414694)`), + /// the terrain under it at the height the live clamp measured before the cage's collider + /// attached (21.958), and a unit at Galen's spawn. Skips without client data. + #[test] + fn galens_cage_from_the_shipped_hull_grounds_him_inside() { + use benilla_assets::coords::wow_to_bevy; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let hull = benilla_formats::load_m2_collision_hull(&mut chain, "World\\Goober\\G_Cage.mdx") + .expect("the cage hull"); + assert_eq!(hull.positions.len(), 8, "one box"); + let verts: Vec = hull.positions.iter().map(|p| wow_to_bevy(*p)).collect(); + let tris: Vec<[u32; 3]> = hull + .indices + .chunks_exact(3) + .map(|c| [c[0], c[1], c[2]]) + .collect(); + let mut app = world(); + const TERRAIN: f32 = 21.958; + let under_cage = wow_to_bevy([-9898.3, -3724.76, TERRAIN]); + floor_around(&mut app, under_cage); + app.world_mut().spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform { + translation: wow_to_bevy([-9898.3, -3724.76, 21.9428]), + rotation: super::super::gameobject_rotation( + Some([0.0, 0.0, 0.909961, 0.414694]), + 0.0, + ), + ..default() + }, + )); + let npc = spawn_unit(&mut app, wow_to_bevy([-9898.53, -3724.63, 22.4164])); + app.update(); + clamp(&mut app); + let y = y_of(&app, npc); + assert!( + (y - TERRAIN).abs() < 1e-3, + "from his seat the ray passes the cage's own floor to the terrain: got {y} (seat 22.4164, lid 24.667)" + ); + } + + /// **A new path re-bases from the server's own Z.** Continuity is one path long: the memo of a + /// walker under a floor that streamed in late is not carried into its next path, whose start + /// is the server's position on that floor — the reference re-bases its mover on every inbound + /// movement packet (`0x7c6420`), and this is that. + #[test] + fn a_new_path_starts_again_from_the_seat() { + let mut app = world(); + floor_at(&mut app, 0.0); + let npc = spawn_unit(&mut app, Vec3::new(0.0, 0.1, 0.0)); + app.update(); + // Walking on the terrain, the building's floor 2 yd up not yet built. + path(&mut app, npc, 1, [0.0, 0.0, 2.1], [4.0, 0.0, 2.1], 2); + clamp(&mut app); + assert!( + (y_of(&app, npc) - 0.0).abs() < 1e-3, + "first frame: from the seat, onto terrain" + ); + for i in 1..=5 { + let mut t = app.world_mut().get_mut::(npc).unwrap(); + t.translation = Vec3::new(i as f32 * 0.1, 2.1, 0.0); + clamp(&mut app); + } + // The floor lands under a walker mid-path: the path continues where it stood (under it). + floor_at(&mut app, 2.0); + app.world_mut().resource_mut::().bump(); + app.update(); + let mut t = app.world_mut().get_mut::(npc).unwrap(); + t.translation = Vec3::new(0.6, 2.1, 0.0); + clamp(&mut app); + assert!( + y_of(&app, npc) < 1.0, + "mid-path the walker continues from where it stood: {}", + y_of(&app, npc) + ); + // The next packet starts a new path from the server's Z on the floor: re-based. + path(&mut app, npc, 2, [0.6, 0.0, 2.1], [4.0, 0.0, 2.1], 2); + let mut t = app.world_mut().get_mut::(npc).unwrap(); + t.translation = Vec3::new(0.7, 2.1, 0.0); + clamp(&mut app); + assert!( + (y_of(&app, npc) - 2.0).abs() < 1e-3, + "a new path is seated from the server's own Z: {}", + y_of(&app, npc) + ); + } +} diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 094d145d..93872c96 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -115,17 +115,17 @@ pub(crate) use follow::{FollowRequest, FollowState}; use state::{ MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO, - SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, - STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WALK_RATIO, WATER_WALK_PITCH_FLOOR, - WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, + SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE_PER_YARD, + TURN_RATE, TURN_RATE_MOVING, WALK_RATIO, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, + WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, }; // `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision // 0737 — residency releases the hold, not ground contact), which owns the deadline push while the // resident world is still the departed map's (0710). pub(crate) use state::{ - Player, PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS, DEFAULT_COLLISION_HEIGHT, - FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, SETTLE_TIMEOUT, - TERMINAL_VELOCITY, + Player, PlayerCapsule, CAPSULE_HEIGHT, CAPSULE_RADIUS, CREATURE_STEP_UP_HEIGHT, + DEFAULT_COLLISION_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, + SETTLE_TIMEOUT, STEP_UP_HEIGHT, TERMINAL_VELOCITY, }; /// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit /// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index a37bc08a..20e14152 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -224,7 +224,12 @@ pub(super) fn control( // let you spin your own body on the ground (decision 1753). Off the MOVER's descriptor, not // ours — `esi` in `0x5144e0` is whatever we are driving (1277) — and a ghost is not dead by it, // because the server puts a released player's health at 1 (0308). - let (stunned, dead) = body + // **The stand state** is read in the same pass for the mouse alone (decision 2025): the + // reference's camera→body hand-off predicate `0x5145e0` ends on `GetStandState() == 0` + // (`0x51460c`, the CGPlayer override `0x5ed570` = the client-predicted cache), so a seated + // body — sitting, in a chair, asleep — is never re-faced by a right-drag, and never stood up + // by one either. Predicted, not merely echoed: `stand_pending` is our `[player+0x1d68]`. + let (stunned, dead, stand_byte) = body .single() .ok() .and_then(|(.., store, _, _, _, _, _)| { @@ -232,10 +237,12 @@ pub(super) fn control( ( s.0.unit_flags() & UNIT_FLAG_STUNNED != 0, s.0.unit_is_dead(), + s.0.unit_stand_state(), ) }) }) - .unwrap_or((false, false)); + .unwrap_or((false, false, 0)); + let stand_state = player.stand_pending.unwrap_or(stand_byte); // The shared precondition `0x5144e0`, assembled once for this tick the way the reference // evaluates it once for the mover — health above, and the far-sight conjunct here. // @@ -255,6 +262,9 @@ pub(super) fn control( // `0x5145b0`, evaluated here because the first thing it suppresses is the mouse turn below. Its // translate sibling waits until after `apply_server_moves`, where the root edge it reads lands. let may_turn = mover.may_turn(stunned); + // `0x5145e0`, the mouse's own hand-off gate — `may_turn` plus the stand-state conjunct the + // keyboard path does not have (it stands you up instead; the mouse is refused). + let mouse_turns_body = mover.mouse_may_turn_body(stunned, stand_state); // Drunkenness (B210): this frame's wobble angle, computed once — the facing veer and the // swim-pitch porpoise ([`swim::drive_step`]) both read it. Zero while sober (`wobble` early-outs on a 0.0 // fraction), and zero whenever the turn predicate is down — the reference's wobble sits behind @@ -339,7 +349,14 @@ pub(super) fn control( // all, because `0x50fee0` (the camera rotate at `0x514446`) runs before any object lookup and // the hand-off simply does not run. Ours writes and puts back, which is indistinguishable // downstream and keeps the approved camera path intact — a shape difference, not a copy. - if !may_turn || player.control_lost || player.reseat { + // + // **A seated body enters through the same door too** (decision 2025). `0x5145e0`'s last + // conjunct is `GetStandState() == 0` — the right-drag orbits the camera round a body that keeps + // facing its chair, sends nothing, and does not stand it up (1766 had already taken the stand + // off this gesture; this takes the turn off it). Stand up — X, a turn key, a flick — and the + // next motion sample hands the camera's yaw to the body again, which is the snap the reference + // shows too: `0x5103e0` commits the camera's own facing, not a delta. + if !mouse_turns_body || player.control_lost || player.reseat { player.face_yaw = yaw_before_look; } { diff --git a/crates/benilla-app/src/player/move_trace.rs b/crates/benilla-app/src/player/move_trace.rs index 4bd40f5b..7077bf3d 100644 --- a/crates/benilla-app/src/player/move_trace.rs +++ b/crates/benilla-app/src/player/move_trace.rs @@ -57,6 +57,11 @@ pub(super) struct Frame { pub dx: f32, pub grounded: bool, pub on_walkable: bool, + /// Whether the keys asked for any horizontal motion this frame. A frame that travelled with + /// **no** input is a body being moved by the resolve alone — a push-out, a slide off a + /// contact — and that is the whole shape of "I sat down and ended up beside the chair" + /// (B359), so it is interesting on its own even while grounded and level. + pub moving: bool, pub vel_y: f32, /// The step-down snap, when the walk-mode block ran: `(probe reach, what the probe found)`; /// the inner pair is `(hit distance, hit normal.y)` — a steep hit is recorded too, so a lip @@ -334,7 +339,16 @@ pub(super) fn frame(f: Frame) { .and_then(|(_, hit)| hit) .map_or(0.0, |(dist, _)| dist); let flipped = f.grounded != PREV_GROUNDED.swap(f.grounded, Ordering::Relaxed); - if !(flipped || !f.grounded || dy.abs() > 0.05 || snap_dist > 0.05 || f.climb.is_some()) { + // Input-less travel: the resolve moved a body nobody was steering. A millimetre a frame is + // the signature (1458's chair extrusion ran at 1–2 mm), so the floor is well under it. + let creep = !f.moving && f.dx > 1.0e-4; + if !(flipped + || !f.grounded + || dy.abs() > 0.05 + || snap_dist > 0.05 + || f.climb.is_some() + || creep) + { return; } let snap = match f.snap { @@ -392,10 +406,11 @@ pub(super) fn frame(f: Frame) { let left = if left { " LEFT-SURFACE" } else { "" }; let climb = f.climb.map_or(String::new(), |t| format!(" climb={t:+.3}")); let anchored = if f.anchored { " ROOTED" } else { "" }; + let creep = if creep { " CREEP" } else { "" }; trace::line( "move", &format!( - "y {:9.3} -> {:9.3} dy={:+.3}{} grounded={} walk={} vy={:+7.2} {}{}{}{}", + "y {:9.3} -> {:9.3} dy={:+.3}{} grounded={} walk={} vy={:+7.2} {}{}{}{}{}", f.y_in, f.y_out, dy, @@ -406,7 +421,8 @@ pub(super) fn frame(f: Frame) { snap, left, climb, - anchored + anchored, + creep ), ); } diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index 88a57f8f..d48bff0f 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -39,7 +39,7 @@ use bevy::prelude::*; use super::{ move_trace, Player, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, - STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, STEP_UP_HEIGHT, TERMINAL_VELOCITY, + STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE_PER_YARD, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES, }; @@ -401,6 +401,7 @@ pub(super) fn step( player.horiz_vel, time.delta(), Support { + rise: STEP_UP_HEIGHT, offset: hover_offset, water: water_floor, steep: player.steep_support, @@ -554,6 +555,7 @@ pub(super) fn step( dx: (center - pre_move).xz().length(), grounded, on_walkable, + moving, vel_y: player.vel_y, snap: snap_probe, climb, @@ -587,8 +589,14 @@ pub(super) fn step( /// /// They travel together because they are the same question asked twice: how far below the feet does /// "the ground" start, and does the thing under us count as a floor at all. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy)] pub(crate) struct Support { + /// **The mover's rise budget** — the reference's `H` (`0x617430`): how high the atomic step-up + /// may lift the body, and the yardstick of its certify reach (`H·tan50°`). [`STEP_UP_HEIGHT`] + /// for a player body — our own, and every watched one — and + /// [`super::CREATURE_STEP_UP_HEIGHT`] for a creature the server walks (decision 1125 has the + /// byte split: `0x5fa550`'s FALSE leg reads the constant `2.0`). + pub(crate) rise: f32, /// The body rests this far **above** the surface — HOVER's float (decision 0866), /// [`super::HOVER_HEIGHT`] while the mode is up and `0.0` for everyone else, which is the /// ordinary case. @@ -612,6 +620,19 @@ pub(crate) struct Support { pub(crate) steep: bool, } +impl Default for Support { + /// A player body on an ordinary frame: no hover, no liquid floor, a walkable support, and the + /// player's own rise budget. + fn default() -> Self { + Self { + rise: STEP_UP_HEIGHT, + offset: 0.0, + water: None, + steep: false, + } + } +} + /// What one grounded walk step resolved against the world came out as ([`grounded_step`]). pub(crate) struct GroundedStep { /// The resolved capsule centre. @@ -682,7 +703,8 @@ pub(crate) fn grounded_step( center, horiz_vel / speed, travel, - travel.max(STEP_UP_ADVANCE), + travel.max(support.rise * STEP_UP_ADVANCE_PER_YARD), + support.rise, ) } else { StepAttempt { @@ -840,7 +862,7 @@ pub(crate) fn grounded_step( let reach = d.x.hypot(d.z) * STEP_SLOPE_RATIO + STEP_SNAP_SLACK + if support.steep || popped.is_some() { - STEP_UP_HEIGHT + support.rise } else { 0.0 } @@ -1287,6 +1309,7 @@ pub(crate) fn step_up( dir_h: Vec3, look: f32, advance: f32, + rise: f32, ) -> StepAttempt { let none = |verdict| StepAttempt { contact: None, @@ -1306,8 +1329,8 @@ pub(crate) fn step_up( verdict, }; - // Rise: the free headroom, at most H. - let up = cast(center, Vec3::Y * STEP_UP_HEIGHT).map_or(STEP_UP_HEIGHT, |h| h.distance); + // Rise: the free headroom, at most H — the mover's own budget (`Support::rise`). + let up = cast(center, Vec3::Y * rise).map_or(rise, |h| h.distance); if up < 1e-3 { return at(StepVerdict::NoHeadroom); } @@ -1350,6 +1373,7 @@ pub(crate) fn step_up( #[cfg(test)] mod tests { use super::*; + use crate::player::state::STEP_UP_ADVANCE; use crate::player::AIR_NUDGE_SPEED; use crate::player::CAPSULE_RADIUS; use bevy::ecs::system::RunSystemOnce; @@ -1427,7 +1451,15 @@ mod tests { let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5, 0.0); let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance); let center = start + Vec3::X * run; - step_up(&cast, center, Vec3::X, TRAVEL_60FPS, advance).verdict + step_up( + &cast, + center, + Vec3::X, + TRAVEL_60FPS, + advance, + STEP_UP_HEIGHT, + ) + .verdict }) .unwrap() } @@ -1809,9 +1841,8 @@ mod tests { Vec3::X * 7.0, dt, Support { - offset: 0.0, - water: None, steep: true, + ..Support::default() }, ); g.center.y - center.y @@ -2001,6 +2032,7 @@ mod tests { Vec3::X, TRAVEL_60FPS, STEP_UP_ADVANCE, + STEP_UP_HEIGHT, ) .verdict }) @@ -2269,8 +2301,8 @@ mod tests { /// **B322, the lift** (decision 1616): the director's *"it doesn't bring you to surface when /// used in water"*, for the case the reference actually surfaces — a body standing in liquid /// too shallow to swim in. The surface is solid geometry over there, so a body inside it is a - /// body inside a wall, and the sweep's depenetration is what puts it on top. Ours is a queried - /// plane with no sweep, so the resolve has to say it. + /// body inside a wall that the swept resolve keeps on top. Ours is a queried plane with no + /// sweep, so the resolve has to say it. #[test] fn water_walking_lifts_a_wader_out_of_the_water() { const SURFACE: f32 = 0.0; diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 8b29a596..56bc4a4e 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -179,7 +179,14 @@ pub(super) const STEP_SNAP_SLACK: f32 = 0.027_777_8; /// 0209's note invited us to fix — "one number to nudge if a real spot feels too restrictive" — /// which the director's captures found twice: a 0.91 yd Stormwind step (`14282v1`, a 66° face onto a /// flat top) and 1121's deferred 1.04 yd ledge. -pub(super) const STEP_UP_HEIGHT: f32 = 1.0; +pub(crate) const STEP_UP_HEIGHT: f32 = 1.0; +/// The rise budget of a body the reference does **not** treat as player-controlled — a creature, a +/// pet, a charmed/possessed/feared/confused/rooted player (decision 1125, wow-re +/// `mover-collision-scalars.md`): `0x617430` takes `0x5fa550`'s FALSE leg and returns the constant +/// `2.0` at `[0x801628]`, where a player's is its own `[CMovement+0xb8]` = 1.0. So a creature steps +/// twice as high as we do, and its certify advance (`H·tan50°`) reaches twice as far; the creature +/// clamp's walker step ([`crate::net::motion::spline::ground_clamp_creatures`]) is the consumer. +pub(crate) const CREATURE_STEP_UP_HEIGHT: f32 = 2.0; /// The step-up **certify advance** (yd): how far ahead the maneuver looks for the tread it would /// stand on. A property of the BODY, never of the frame (decision 1121). /// @@ -209,6 +216,11 @@ pub(super) const STEP_UP_HEIGHT: f32 = 1.0; /// whose *face* is unwalkable for most of a yard before the flat top begins, where a probe that stops /// at one radius is still over the face and reads it as a steep floor. pub(super) const STEP_UP_ADVANCE: f32 = 1.191_753_6; +/// The certify reach **per yard of rise budget** — `tan 50°`, since the reference's reach is +/// `max(H·tan50°, r + 1/720)` with `H` the mover's own budget (`0x636147`–`0x636190`, wow-re +/// `ret2-commit-law.md`). [`STEP_UP_ADVANCE`] is this at the player's `H` of 1.0; a creature's 2.0 +/// (`CREATURE_STEP_UP_HEIGHT`) reaches 2.38 yd through the same law. +pub(super) const STEP_UP_ADVANCE_PER_YARD: f32 = STEP_UP_ADVANCE / STEP_UP_HEIGHT; /// The **foot cone's height** (yd): how far above the feet the reference's movement solid is still /// narrower than its full radius — and therefore the band within which a blocking edge is *ridden /// up* rather than stepped onto (decision 1123). @@ -598,6 +610,32 @@ impl MoverInput { self.ready() && !stunned } + /// **`0x5145e0` — "may the MOUSE hand the camera's facing to the body?"** (decision 2025). The + /// third predicate, consumed at `0x514474` (`je 0x514480`) in the mouse-MOVE handler `0x514400` + /// to gate the camera→body hand-off `0x51447b call 0x5103e0`, and again at `0x51495a` for the + /// both-button site. Its conjuncts, in the binary's order: the stun predicate above + /// (`0x5145eb call 0x5145b0`, which carries the shared precondition), the mouse-look input bit + /// (`0x5145fa test byte [esi+4],1` — the session's own right-button condition, which is the + /// caller's here), and **`GetStandState() == 0`** (`0x51460c call [eax+0xa4]` → the CGPlayer + /// override `0x5ed570`, the client-predicted cache `[player+0x1d68]`; `0x514612 neg; sbb; inc`). + /// The commit is refused a second, independent time downstream: `0x5151b0` re-tests the raw + /// descriptor byte at `0x51520a` (wow-re `local-move-input-gate.md` §6.4, + /// `standstate-movement-trigger.md` §4.1, both VERIFIED). + /// + /// So a right-drag while **seated** — sitting, in a chair (2, and the server-driven 4/5/6), + /// asleep — turns the camera and leaves the body exactly where it is, and puts nothing on the + /// wire. It does not stand the body up either: the turn emitter `0x514f50` skips its stand arm + /// while the RMB bit is held (`0x514f6d`), and `MSG_MOVE_SET_FACING` is exempt from the + /// emitter's stand table. Standing up is the keyboard's (`turned`), the X key's, or a flick's + /// (1766); the moment the predicted stand state reads 0 again, the next motion sample hands + /// the camera's yaw to the body like any other right-drag. + /// + /// `stand_state` is the **predicted** value — our `stand_pending` overlaid on the descriptor + /// byte — because that is what `0x5ed570` returns for the active player. + pub(crate) fn mouse_may_turn_body(self, stunned: bool, stand_state: u8) -> bool { + self.may_turn(stunned) && stand_state == 0 + } + /// **The input tick's teardown leg** — `0x5146d6 call 0x60fb60(0, 1)`, which cancels /// click-to-move and `/follow` and fires `AUTOFOLLOW_END` (event `0x170`). /// @@ -1834,6 +1872,44 @@ mod move_mode_tests { /// server's root on death, and a pure root leaves the pivot live *on purpose*, so a dead body /// could be spun with the turn keys or a right-drag. The predicate that stops it must not need /// the root, the stun, or the server's cooperation. + /// **A seated body is not re-faced by the mouse** (decision 2025): `0x5145e0`'s last conjunct + /// is `GetStandState() == 0`, so sitting, a chair (client 2 and the server's 4/5/6) and sleep + /// all refuse the camera→body hand-off — while the keyboard turn (`may_turn`) is untouched, + /// because that path stands you up instead of being refused. + #[test] + fn a_seated_body_refuses_the_mouse_turn_and_keeps_the_keyboard_one() { + let m = MoverInput { + dead: false, + view_is_out: false, + }; + for seated in [1u8, 2, 3, 4, 5, 6] { + assert!( + !m.mouse_may_turn_body(false, seated), + "stand state {seated}: the mouse hand-off is refused at `0x51460c`" + ); + assert!( + m.may_turn(false), + "stand state {seated}: the keyboard turn is not refused — it stands the body" + ); + } + assert!( + m.mouse_may_turn_body(false, 0), + "standing: the hand-off runs" + ); + assert!( + !m.mouse_may_turn_body(true, 0), + "stunned: refused through `0x5145b0` before the stand state is even read" + ); + assert!( + !MoverInput { + dead: true, + view_is_out: false + } + .mouse_may_turn_body(false, 0), + "dead: refused through the shared precondition `0x5144e0`" + ); + } + #[test] fn death_drops_both_movement_input_predicates() { // (dead, rooted, stunned) -> (may_translate, may_turn) diff --git a/crates/benilla-app/src/player/step_probe.rs b/crates/benilla-app/src/player/step_probe.rs index 7b9abf55..b5b37d20 100644 --- a/crates/benilla-app/src/player/step_probe.rs +++ b/crates/benilla-app/src/player/step_probe.rs @@ -196,7 +196,7 @@ pub(super) fn watch( let rungs: Vec = std::iter::once(wanted) .chain(LADDER) .map(|adv| { - let v = step_up(&cast, from, dir_h, adv.max(wanted), adv).verdict; + let v = step_up(&cast, from, dir_h, adv.max(wanted), adv, STEP_UP_HEIGHT).verdict; // `fwd` — how far the elevated sweep ACTUALLY got — is on every rung, because without // it a failing far rung is two different stories: "we advanced that far and the floor // there is steep" and "something at head height stopped us short of it". diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index bf0aac88..5d7b2cf6 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -20,12 +20,22 @@ //! quad pass, so a scene becomes a **tile**: every visible pane holding a file renders into its //! own cell of one shared render-target atlas, at the pane's device-pixel size, through ONE //! orthographic camera whose view is the atlas plane — each tile's model root is placed at its -//! cell, scaled to pixels per model unit, and the camera never moves. The extract's `ModelPane` -//! arm then draws the cell as a premultiplied quad at `ZKey::callback(Artwork)` (1995's rank — -//! after every texture and font string of the pane's layer), which is the same picture the -//! reference's callback drain produces: the cell is cleared to transparent like the reference -//! clears its depth, the 2-D layers under it stay under it, and the ones over it stay over it. -//! Cells never overlap, so one depth buffer serves every tile. +//! cell, scaled to pixels per model unit, and the camera never moves. [`compose_tiles`] then +//! draws every cell as a premultiplied quad over its pane's rect at `ZKey::callback(Artwork)` +//! (1995's rank — after every texture and font string of the pane's layer), which is the same +//! picture the reference's callback drain produces: the cell is cleared to transparent like the +//! reference clears its depth, the 2-D layers under it stay under it, and the ones over it stay +//! over it. Cells never overlap, so one depth buffer serves every tile. +//! +//! **The composite is this renderer's per-frame output, never the extract's** (decision 2023). +//! The extract's `ModelPane` arm publishes the request — the pane's rect, paint key, alpha and +//! clip beside the unit ladder — and pushes no quad; the quad is appended in the +//! [`UiQuadAppend`] lane (the minimap fill's lane) from THIS frame's cells. The first shape had +//! the arm draw the cell it found in the bridge, which is last frame's at best and, because the +//! conversion is memoized on the engine's list, usually never: a cooldown armed on a quiet +//! interface extracted once (no cell yet), the cell arrived a frame later, and nothing ever +//! re-ran the conversion — the sweep drew only while the interface happened to be churning (the +//! stance bar at UI load), and never on an action press. //! //! The pipeline is the booths' (`crate::portrait`): the same HDR view shape, the same //! `FfxGlow::UI_PANE` decode, the same material twin with only the light storage swapped, the @@ -76,7 +86,7 @@ use benilla_ui::script::{ModelPaneFrame, UiScript}; use benilla_ui::widget::{FrameHandle, ModelFileFacts, SequenceFacts}; use benilla_world::doodad_anim::spawn_anim_host; use benilla_world::lighting::LightBlob; -use benilla_world::mat_anim_table::{affine_row, MatAnimTable}; +use benilla_world::mat_anim_table::{affine_row, MatAnimMirrors, MatAnimTable}; use benilla_world::model_forms::ModelForms; use benilla_world::model_render::M2BatchMaterials; use benilla_world::particles::buffer::EffectLightOverride; @@ -89,6 +99,19 @@ use benilla_world::rig_palette::{RigPaletteMirrors, RigPalettes, RigPart, RigSki use crate::portrait::{ booth_view_shape, material_variant, new_target_image_sized, StageRig, UI_MODELS_LAYER, }; +use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect}; + +/// `WOW_TILE_TRACE=1` — the tile probe: one `tile-trace:` line per pane per frame from the +/// renderer (the request, the cell, the play head, the sampled alphas and the rows written) and +/// one from the extract's composite arm (the quad's rect, rank and alpha, or "no cell yet"). +/// A pane that is on the engine's paint list but draws nothing names the gate it stopped at. +/// The `test_ui` cooldown tests prove the engine scrubs; this is the instrument for the half +/// they cannot reach — whether the tile exists, where it is, and what it sampled. Read once. +pub(crate) fn trace_on() -> bool { + static ON: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::env::var("WOW_TILE_TRACE").as_deref() == Ok("1")); + *ON +} /// One pane's request for a tile this frame — what the extract knows about the widget: its /// size on the device, the unit ladder the render law derives from it, and the Lua-set scene. @@ -115,6 +138,15 @@ pub(crate) struct TileRequest { pub position: Vec3, /// `ReplaceIconTexture`'s path — the type-14 batches' texture. pub icon: Option, + /// The pane's rect on the window — y-down logical px, the quad pass's space — where the + /// cell composites. + pub rect: Rect, + /// The pane's paint key: `ZKey::callback(Artwork)` (1995), the composite's rank. + pub z_key: u64, + /// The frame's OWN alpha (render law §4.4): the composite draws at it. + pub alpha: f32, + /// The enclosing ScrollFrame clip, if any (decision 0112), in the quad pass's space. + pub clip: Option, } /// Where a tile sits in the atlas — texel space, `y` down — for the composite quad. @@ -307,7 +339,10 @@ impl Plugin for UiModelsPlugin { .add_systems(Startup, setup_tiles) // After the extract published this frame's requests, and before the pose/palette // passes read the roots' transforms (they run in PostUpdate). - .add_systems(Update, sync_tiles.after(crate::ui_script::UiInput)); + .add_systems(Update, sync_tiles.after(crate::ui_script::UiInput)) + // The composite: this frame's cells, appended in the lane the minimap fill uses — + // after the cells are packed, before the mesh rebuild reads the lane. + .add_systems(Update, compose_tiles.in_set(UiQuadAppend).after(sync_tiles)); } } @@ -317,14 +352,19 @@ fn setup_tiles( device: Res, queue: Res, mut mirrors: ResMut, + mut anim_mirrors: ResMut, ) { // ``'s embedded light is disabled: no ambient, no diffuse, no fog (render law §5.2). // The direction is irrelevant at zero intensity; the builder wants one. let blob = LightBlob::model([0.0; 3], [0.0; 3], Vec3::NEG_Y); let light = blob.create(&device, "wow_ui_model_light"); blob.write(&queue, &light); - // Tile rigs skin from THIS buffer's palette region (decision 0720's mirror law). + // Tile rigs skin from THIS buffer's palette region (decision 0720's mirror law), and the + // tiles' animated materials read their mat-anim rows from it too (decision 2023): a twin + // binds this buffer, not the world's, so the table has to be mirrored here or the rows the + // tiles write every frame reach a buffer nothing in a tile ever samples. mirrors.0.insert("ui_models", light.clone()); + anim_mirrors.0.insert("ui_models", light.clone()); let layer = RenderLayers::layer(UI_MODELS_LAYER); commands.spawn(( Name::new("ui model tiles camera"), @@ -464,9 +504,25 @@ fn sync_tiles( let mut live: Vec<(FrameHandle, TileRequest, ModelPaneFrame)> = Vec::new(); for pane in panes { let Some(req) = bridge.requests.get(&pane.handle) else { + if trace_on() { + info!( + "tile-trace: pane {:?} {} (under {}) on the paint list, not extracted yet", + pane.handle, + script.frame_name(pane.handle).unwrap_or_default(), + script + .target_owner_name(benilla_ui::order::ZTarget::Frame(pane.handle)) + .unwrap_or_default() + ); + } continue; // not extracted yet — next frame }; if req.size_px.x == 0 || req.size_px.y == 0 { + if trace_on() { + info!( + "tile-trace: {} pane {:?} has a zero rect", + req.path, pane.handle + ); + } continue; } live.push((pane.handle, req.clone(), pane)); @@ -478,6 +534,9 @@ fn sync_tiles( for (handle, req, _) in &live { let key = benilla_ui::widget::model_key(&req.path); let Some(m2) = state.loaded.get(&key).cloned() else { + if trace_on() { + info!("tile-trace: {} facts not landed (key {key})", req.path); + } continue; // facts not landed ⇒ the engine would not have listed it; defensive }; let stale = state @@ -535,12 +594,26 @@ fn sync_tiles( built.emitters.len(), built.alpha_parts.len() ); + if trace_on() { + for (i, p) in built.uv_parts.iter().enumerate() { + info!( + "tile-trace: {} uv part {i}: trans slot {:?} affine slot {:?}", + req.path, + p.trans.map(|(s, _)| s), + p.affine + ); + } + } tile.clips = built.clips; tile.alpha_parts = built.alpha_parts; tile.uv_parts = built.uv_parts; tile.emitters = built.emitters; tile.built = true; + } else if trace_on() { + info!("tile-trace: {} waiting on materials", req.path); } + } else if trace_on() { + info!("tile-trace: {} asset not resident", req.path); } } } @@ -663,8 +736,12 @@ fn sync_tiles( facts_slot }); let gseq_s = pane.clock_ms as f64 / 1000.0; + let mut trace_alphas: Vec = Vec::new(); for part in &tile.alpha_parts { let a = part.anim.sample(seq_slot, cursor_s, gseq_s); + if trace_on() { + trace_alphas.push(a); + } if let Ok((mut tag, mut pvis)) = parts.get_mut(part.entity) { // The `A ≤ 0` cull (wow-re `m2-alpha-combine-cull`): a batch the artist keyed // off in this sequence is skipped, not drawn at zero. @@ -685,6 +762,34 @@ fn sync_tiles( for part in &tile.uv_parts { part.write_rows(&mut render.table, seq_slot, cursor_s, gseq_s); } + if trace_on() { + let rows: Vec = tile + .uv_parts + .iter() + .map(|p| { + let t = p.trans.map(|(s, _)| render.table.row(s)); + let a = p.affine.map(|s| render.table.row(s)); + format!("t={t:?} a={a:?}") + }) + .collect(); + info!( + "tile-trace: {} {} cell=({},{} {}x{}) px/unit={:.2} pos_px/unit={:.2} armed={:?} cursor={:.3}s slot={:?} clock={}ms alphas={:?} rows=[{}]", + req.path, + script.frame_name(*handle).unwrap_or_default(), + cell.origin.x, + cell.origin.y, + cell.size.x, + cell.size.y, + req.px_per_unit, + req.pos_px_per_unit, + armed, + cursor_s, + seq_slot, + pane.clock_ms, + trace_alphas, + rows.join(", ") + ); + } for &e in &tile.emitters { if let Ok(mut em) = emitters.get_mut(e) { em.set_size_scale(req.star_px_per_unit); @@ -724,6 +829,53 @@ fn set_camera_active( } } +/// The composite: one premultiplied quad per cell packed THIS frame, over its pane's rect at the +/// pane's paint key and alpha, clipped as the pane is — appended to the UI pass's overlay lane +/// every frame (the lane is cleared at the top of [`UiQuadAppend`] and diffed by the rebuild, so +/// an unchanged set costs no re-batch). A pane with a request and no cell draws nothing; a cell +/// whose request vanished (the linger reaper) draws nothing. +pub(crate) fn compose_tiles(bridge: Res, mut quads: ResMut) { + quads.overlays.extend(composite_quads(&bridge)); +} + +/// [`compose_tiles`]'s pure half: the quads for every `(request, cell)` pair the bridge holds, +/// ordered by paint key so the overlay diff sees the same sequence for the same set. +pub(crate) fn composite_quads(bridge: &UiModelTiles) -> Vec { + let Some(atlas) = bridge.atlas.clone() else { + return Vec::new(); + }; + let a = bridge.atlas_size.as_vec2(); + if a.x <= 0.0 || a.y <= 0.0 { + return Vec::new(); + } + let mut out: Vec = bridge + .cells + .iter() + .filter_map(|(handle, cell)| { + let req = bridge.requests.get(handle)?; + let (u0, v0) = (cell.origin.x as f32 / a.x, cell.origin.y as f32 / a.y); + let (u1, v1) = ( + (cell.origin.x + cell.size.x) as f32 / a.x, + (cell.origin.y + cell.size.y) as f32 / a.y, + ); + Some(UiQuad { + rect: req.rect, + z_key: req.z_key, + texture: Some(atlas.clone()), + uv: UvRect::from_tex_coords([u0, u1, v0, v1]), + // The instance draws at the widget's OWN alpha (render law §4.4). + color: [1.0, 1.0, 1.0, req.alpha], + // A render target: premultiplied by construction (`UiQuad` doc). + premultiplied: true, + clip: req.clip, + ..default() + }) + }) + .collect(); + out.sort_by_key(|q| q.z_key); + out +} + /// The engine's facts for a resident file: its sequence table and header bounds. fn facts_of(model: &M2Model) -> ModelFileFacts { ModelFileFacts { @@ -1035,6 +1187,68 @@ mod tests { assert_eq!(atlas, UVec2::splat(ATLAS_MAX)); } + /// The composite is a function of the bridge alone (decision 2023): a request with no cell + /// draws nothing, a cell draws its request's rect at the request's key and alpha with the + /// cell's texel window, and a cell whose request is gone draws nothing — no extract in the + /// loop. + #[test] + fn the_composite_is_the_bridges_cells_over_their_requests() { + // Two live handles off a real arena — the bridge is keyed by them, nothing more. + let mut arena = benilla_ui::widget::WidgetArena::new(); + let handle = arena.create(benilla_ui::widget::FrameKind::Frame, None, None); + let stray = arena.create(benilla_ui::widget::FrameKind::Frame, None, None); + let req = TileRequest { + path: r"Interface\Cooldown\UI-Cooldown-Indicator.mdx".into(), + size_px: UVec2::new(63, 63), + px_per_unit: 1680.75, + pos_px_per_unit: 2742.62, + star_px_per_unit: 2742.62, + facing: 0.0, + position: Vec3::ZERO, + icon: None, + rect: Rect::new(303.2, 767.1, 334.9, 798.8), + z_key: 3_458_840_389_530_157_056, + alpha: 0.5, + clip: Some(Rect::new(0.0, 700.0, 400.0, 800.0)), + }; + let mut bridge = UiModelTiles::default(); + bridge.requests.insert(handle, req.clone()); + assert!( + composite_quads(&bridge).is_empty(), + "no atlas, no cell: nothing" + ); + bridge.atlas = Some(Handle::default()); + bridge.atlas_size = UVec2::splat(512); + assert!(composite_quads(&bridge).is_empty(), "no cell yet: nothing"); + bridge.cells.insert( + handle, + Cell { + origin: UVec2::new(67, 2), + size: UVec2::new(63, 63), + }, + ); + // A cell the reaper's request drop orphaned: nothing to place it at. + bridge.cells.insert( + stray, + Cell { + origin: UVec2::new(2, 2), + size: UVec2::new(63, 63), + }, + ); + let quads = composite_quads(&bridge); + assert_eq!(quads.len(), 1, "one cell with a request draws once"); + let q = &quads[0]; + assert_eq!(q.rect, req.rect); + assert_eq!(q.z_key, req.z_key); + assert_eq!(q.color, [1.0, 1.0, 1.0, 0.5], "the frame's own alpha"); + assert!(q.premultiplied); + assert_eq!(q.clip, req.clip); + assert!(q.texture.is_some()); + let [tl, _, br, _] = q.uv.corners; + assert!((tl[0] - 67.0 / 512.0).abs() < 1e-6 && (tl[1] - 2.0 / 512.0).abs() < 1e-6); + assert!((br[0] - 130.0 / 512.0).abs() < 1e-6 && (br[1] - 65.0 / 512.0).abs() < 1e-6); + } + /// The axis fix is a proper rotation that puts WoW `+X` right, `+Y` up, `+Z` toward the /// viewer. #[test] diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 369e8604..67c0f830 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -1330,9 +1330,12 @@ fn convert_entry( } => { use crate::portrait::PortraitSource; // A FILE pane (decision 2008): publish what the tile renderer needs — the pane's - // device-pixel size and the render law's unit ladder off it — and draw the tile's - // atlas cell when the renderer has one. The request is idempotent, so the memoized - // conversion may re-publish it freely; which panes draw is the engine's paint list. + // device-pixel size, the render law's unit ladder off it, and where the composite + // goes (its rect, paint key, alpha and clip). The request is idempotent, so the + // memoized conversion may re-publish it freely; which panes draw is the engine's + // paint list, and the quad itself is the renderer's per-frame output + // (`ui_models::compose_tiles`, decision 2023) — NOT pushed here, because a cell + // packed after this conversion would wait on a re-conversion nothing triggers. if let Some(path) = model.as_deref() { let tiles = &mut booths.tiles; let dpi = tiles.dpi.max(0.01); @@ -1357,27 +1360,27 @@ fn convert_entry( facing, position: Vec3::new(position.0, position.1, position.2), icon: icon.clone(), - }, - ); - if let (Some(cell), Some(atlas)) = (tiles.cells.get(&handle), tiles.atlas.clone()) { - let a = tiles.atlas_size.as_vec2(); - let (u0, v0) = (cell.origin.x as f32 / a.x, cell.origin.y as f32 / a.y); - let (u1, v1) = ( - (cell.origin.x + cell.size.x) as f32 / a.x, - (cell.origin.y + cell.size.y) as f32 / a.y, - ); - out.push(UiQuad { rect, z_key: eq.z, - texture: Some(atlas), - uv: UvRect::from_tex_coords([u0, u1, v0, v1]), // The instance draws at the widget's OWN alpha (render law §4.4). - color: [1.0, 1.0, 1.0, own_alpha], - // A render target: premultiplied by construction (`UiQuad` doc). - premultiplied: true, + alpha: own_alpha, clip, - ..default() - }); + }, + ); + if crate::ui_models::trace_on() { + info!( + "tile-trace: composite {path} pane {handle:?} {} rect=({:.1},{:.1})-({:.1},{:.1}) size_px={}x{} z={:?} alpha={own_alpha:.2} cell={:?} atlas={:?}", + name.as_deref().unwrap_or(""), + rect.min.x, + rect.min.y, + rect.max.x, + rect.max.y, + size_px.x, + size_px.y, + eq.z, + tiles.cells.get(&handle), + tiles.atlas_size + ); } return; } diff --git a/crates/benilla-app/src/ui_script/minimap_tests.rs b/crates/benilla-app/src/ui_script/minimap_tests.rs index 9f906777..a2c5a5b1 100644 --- a/crates/benilla-app/src/ui_script/minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/minimap_tests.rs @@ -452,9 +452,14 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() { /// **The ping's pixels are the stock ``'s own file** (decision 2008): shown by /// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the -/// pane — at its device size and the render law's unit ladder — and, once the renderer has -/// handed back a cell, draws that cell as one premultiplied quad over the pane's rect. Drives the -/// real `drive_script` in the headless harness the clip-plumb tests use. +/// pane — at its device size, the render law's unit ladder, and the composite's rect/key/alpha +/// — and pushes no quad of its own. Once the renderer has handed back a cell, the cell draws as +/// one premultiplied quad over the pane's rect **on the very next frame, with nothing else in +/// 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 +/// use. #[test] fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { use bevy::prelude::*; @@ -541,18 +546,37 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { }, PrimaryWindow, )); - app.add_systems(Update, super::extract::drive_script); + // The extract, then the composite off the bridge — the app's own order (the appender runs + // in the `UiQuadAppend` window, after `sync_tiles`, which packs the cells this harness hands + // over by hand below). The lane is cleared by hand between frames, as `clear_ui_overlays` + // does at the top of every append window. + app.add_systems( + Update, + ( + super::extract::drive_script, + crate::ui_models::compose_tiles, + ) + .chain(), + ); app.update(); let premultiplied = |app: &App| { - app.world() - .resource::() - .quads + let quads = app.world().resource::(); + assert!( + quads.quads.iter().all(|q| !q.premultiplied), + "the base lane never carries a tile: the composite is not the extract's" + ); + quads + .overlays .iter() .filter(|q| q.premultiplied) .cloned() .collect::>() }; + let next_frame = |app: &mut App| { + app.world_mut().resource_mut::().overlays.clear(); + app.update(); + }; { let tiles = app.world().resource::(); let req = tiles @@ -580,11 +604,19 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { req.star_px_per_unit ); assert_eq!(req.icon, None); + // The composite's own inputs: the pane's rect in the quad pass's space (y-down window + // px), its callback rank, its own alpha. + assert!( + (req.rect.width() - 50.0).abs() < 1e-3 && (req.rect.height() - 50.0).abs() < 1e-3, + "{:?}", + req.rect + ); + assert!((req.alpha - 1.0).abs() < 1e-6); assert!(premultiplied(&app).is_empty(), "no cell yet: nothing drawn"); } - // The renderer hands a cell back; a fresh ping moves the pane, so the memoized conversion - // runs again and the arm draws the cell. + // The renderer hands a cell back. NOTHING else changes — no ping, no frame moves, the + // memoized conversion skips — and the next frame draws the cell anyway. { let atlas = app .world_mut() @@ -601,15 +633,9 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { }, ); } - { - let mut script = app.world_mut().non_send_resource_mut::(); - // The stock handler seats the frame from `GetPingPosition()`, not the event's args. - script.set_minimap_ping((-0.125, 0.25)); - ping_at(&mut script, -0.125, 0.25); - } - app.update(); + next_frame(&mut app); let drawn = premultiplied(&app); - assert_eq!(drawn.len(), 1, "the cell, once"); + assert_eq!(drawn.len(), 1, "the cell, once, on a quiet frame"); let q = &drawn[0]; assert!((q.rect.width() - 50.0).abs() < 1e-3 && (q.rect.height() - 50.0).abs() < 1e-3); let [tl, _, br, _] = q.uv.corners; @@ -617,4 +643,24 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() { assert!((br[0] - 52.0 / 512.0).abs() < 1e-6 && (br[1] - 52.0 / 512.0).abs() < 1e-6); assert_eq!(q.color, [1.0, 1.0, 1.0, 1.0], "the frame's own alpha"); assert!(q.texture.is_some()); + // And every quiet frame after it — the lane is re-emitted per frame, not on change. + next_frame(&mut app); + assert_eq!( + premultiplied(&app).len(), + 1, + "still drawn on the frame after" + ); + + // A fresh ping moves the pane: the conversion runs again, the request follows the rect, and + // the composite follows the request — still exactly one quad. + { + let mut script = app.world_mut().non_send_resource_mut::(); + // The stock handler seats the frame from `GetPingPosition()`, not the event's args. + script.set_minimap_ping((-0.125, 0.25)); + ping_at(&mut script, -0.125, 0.25); + } + next_frame(&mut app); + let moved = premultiplied(&app); + assert_eq!(moved.len(), 1, "one quad after the pane moved"); + assert_ne!(moved[0].rect, q.rect, "the composite followed the pane"); } diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 8397acb6..3a2da7fd 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -32,12 +32,23 @@ pub(crate) struct UiSessionPlugin; impl Plugin for UiSessionPlugin { fn build(&self, app: &mut App) { - // In `WorldStage::Net` so the facing chain can order itself against it. Deliberately - // unordered w.r.t. the apply pass: the sessions it reads are open for *seconds*, so - // whether a window's first frame is seen now or next frame is invisible against an - // ~8-frame facing ease. - app.init_resource::() - .add_systems(Update, feed_interact_npc.in_set(WorldStage::Net)); + // Seated INSIDE the net chain: after the apply pass that opens and swaps the sessions + // it reads, before the facing chain that reads its answer. **Same frame, not next + // frame** — this used to be "deliberately unordered w.r.t. the apply pass", on the + // reasoning that a window is open for seconds and one frame is invisible against an + // ~8-frame facing ease. That was true for the facing and false for the token: the unit + // feed runs after this set and the window feeds run after the unit feed, so the frame a + // `SMSG_LIST_INVENTORY` opened or swapped the merchant window was the frame its own + // `MERCHANT_SHOW` handler read `UnitName("NPC")` — and this had not yet been told. The + // title read the PREVIOUS NPC's name over the new vendor's stock, and a second vendor + // opened over an open window never swapped the name at all (decision 2022). + app.init_resource::().add_systems( + Update, + feed_interact_npc + .in_set(WorldStage::Net) + .after(crate::net::apply_net_updates) + .before(crate::net::drive_display_facing), + ); } } @@ -150,7 +161,7 @@ pub(crate) fn close_npc_session_out_of_range( /// decision-0105 face bake, wired to the interaction arc's NPC (decision 0081). `None` = no NPC /// window open, so the booth empties; the ring is hidden with its window then, so the dark disc /// never shows. -#[derive(Resource, Default)] +#[derive(Resource, Default, PartialEq, Eq)] pub(crate) struct InteractNpc(pub(crate) Option, pub(crate) Option); /// Collapse the portrait-bound sessions into [`InteractNpc`] each frame: the open one's guid, @@ -209,8 +220,15 @@ pub(crate) fn feed_interact_npc( // token's name (a name lives in the `NameCache`, keyed by guid — there is no way back to one // from an entity). Both are set together and cleared together; a guid whose entity is not // streamed still names the unit, which is why they are two fields rather than one lookup. - out.0 = guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied())); - out.1 = guid; + // + // Written only on a change, so `is_changed()` means it: the unit feed's dirty gate lists + // this resource among its inputs (decision 2022), and an unconditional write every frame + // would hold that gate open for the whole session. + let next = InteractNpc( + guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied())), + guid, + ); + out.set_if_neq(next); } #[cfg(test)] @@ -554,4 +572,80 @@ mod tests { `feed_interact_npc`'s chain, or add them to EXCLUDED with a reason: {missing:#?}" ); } + + /// The seam's ORDER, pinned in the BUILT schedule graph rather than in the source (decision + /// 2022): `feed_interact_npc` runs after the net apply that opens and swaps the sessions it + /// reads, and before the facing chain that reads its answer. Mounted for real — a `.after()` + /// against a system that is not in the schedule is silently nothing, so a source grep could + /// not tell a live edge from a dead one — and asked by system identity, not by name (the + /// debug names are a feature that may be off). + /// + /// Why it is worth a graph walk: the writer used to sit ahead of the whole chain, which is + /// invisible for an 8-frame facing ease and exactly the frame the `"npc"` unit token was + /// stale on when a window's own `MERCHANT_SHOW` handler read it. + #[test] + fn the_interact_npc_writer_is_seated_between_the_net_apply_and_the_facing_chain() { + use bevy::ecs::schedule::graph::Direction::{Incoming, Outgoing}; + use bevy::ecs::schedule::{NodeId, Schedule}; + use bevy::ecs::system::{IntoSystem, System}; + use std::any::TypeId; + use std::collections::HashSet; + + /// `a` runs before `b` in the built graph: the dependency DAG walked with the hierarchy + /// DAG unfolded — a set `a` sits in precedes whatever that set precedes, and a set + /// reached as a successor carries every member with it. + fn runs_before(schedule: &Schedule, a: NodeId, b: NodeId) -> bool { + let dep = schedule.graph().dependency().graph(); + let hier = schedule.graph().hierarchy().graph(); + let (mut after, mut containers) = (HashSet::new(), HashSet::new()); + let mut work = vec![(a, false)]; + while let Some((n, is_after)) = work.pop() { + let fresh = if is_after { + after.insert(n) + } else { + containers.insert(n) + }; + if !fresh { + continue; + } + work.extend(dep.neighbors_directed(n, Outgoing).map(|m| (m, true))); + work.extend(hier.neighbors_directed(n, Incoming).map(|p| (p, false))); + if is_after { + work.extend(hier.neighbors_directed(n, Outgoing).map(|c| (c, true))); + } + } + after.contains(&b) + } + + let apply = System::type_id(&IntoSystem::into_system(crate::net::apply_net_updates)); + let writer = System::type_id(&IntoSystem::into_system(feed_interact_npc)); + let facing = System::type_id(&IntoSystem::into_system(crate::net::drive_display_facing)); + + let mut app = App::new(); + app.add_plugins((crate::net::NetPlugin { connect: false }, UiSessionPlugin)); + // `schedule_scope`, not `resource_scope::`: initializing the schedule + // registers things that insert `Schedules` themselves, which a resource scope refuses. + app.world_mut().schedule_scope(Update, |world, schedule| { + schedule + .initialize(world) + .expect("the Update schedule builds"); + let key = |id: TypeId| -> NodeId { + schedule + .systems() + .expect("initialized") + .find(|&(_, s)| System::type_id(&**s) == id) + .map(|(k, _)| NodeId::System(k)) + .expect("the system is in Update") + }; + let (apply, writer, facing) = (key(apply), key(writer), key(facing)); + assert!( + runs_before(schedule, apply, writer), + "feed_interact_npc must run after apply_net_updates" + ); + assert!( + runs_before(schedule, writer, facing), + "drive_display_facing must run after feed_interact_npc" + ); + }); + } } diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 66ac069a..f955bfe2 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -1375,6 +1375,11 @@ fn feed_units( let group_changed = group.is_changed(); let reps_changed = reputations.is_changed(); let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); + // The interaction NPC is an input of the `"npc"` snapshot below, and it moves on frames + // nothing else does: a vendor window opening or swapping to a second vendor changes no + // descriptor, no selection, no group. Without this term such a frame skipped the whole + // feed and the window's own `MERCHANT_SHOW` handler read the previous NPC (decision 2022). + let interact_changed = interact.as_ref().is_some_and(|r| r.is_changed()); gate::trace( "feed_units", &[ @@ -1387,6 +1392,7 @@ fn feed_units( ("group", group_changed), ("reputations", reps_changed), ("factions", factions_changed), + ("interact", interact_changed), ], ); let gate = gate::Gate::new( @@ -1398,7 +1404,8 @@ fn feed_units( || stores_removed || group_changed || reps_changed - || factions_changed, + || factions_changed + || interact_changed, ); stores.removed.clear(); if gate.skip() { @@ -1640,7 +1647,12 @@ fn feed_units( Some(s) }); // "Absence IS data" again: closing an NPC window must clear the token, or the next window's - // first frame paints the last NPC's name. + // first frame paints the last NPC's name. **The memo is written here, not only read**: for + // its first eight days this diff compared against a row nothing ever inserted, so a `Some` + // re-pushed every frame and a `None` never cleared — `UnitExists("npc")` stayed true after + // the window closed, and the stale name was what the next window's first frame painted + // (decision 2022). No `fire_transitions` leg: the reference's watch bridge fires `UNIT_*` + // for the frames that draw a unit, and nothing draws `"npc"` as a unit frame. let npc_dirty = match (&npc, memo.last.get("npc")) { (Some(cur), Some(prev)) => cur != prev, (None, None) => false, @@ -1649,6 +1661,14 @@ fn feed_units( if npc_dirty { gate.audit("feed_units", "the interaction-NPC snapshot"); script.set_unit("npc", npc.clone()); + match &npc { + Some(cur) => { + memo.last.insert("npc".to_string(), cur.clone()); + } + None => { + memo.last.remove("npc"); + } + } } // The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only @@ -2524,4 +2544,86 @@ mod tests { ); assert_eq!(rest_state_message(1, 5), None); } + + /// The `"npc"` token follows the interaction NPC on the frame it moves — including a frame + /// on which NOTHING else moves — and is cleared when the window closes (decision 2022). The + /// legs are the vendor-swap probe's first run, in order: a second vendor opened over an + /// open window kept the first vendor's snapshot (the dirty gate had no interact input), and + /// a closed window left `UnitExists("npc")` true (the memo row was never written). The + /// feed runs in a real `Update` schedule rather than `run_system_once`, because a fresh + /// system instance sees every resource as changed and would hold the gate open by itself. + #[test] + fn the_npc_token_follows_the_interaction_npc_and_clears_with_it() { + use crate::ui_session::InteractNpc; + use benilla_protocol::messages::ObjectFields; + + const FIELD_UNIT_LEVEL: u16 = 34; + // Two `HIGHGUID_UNIT` guids (the high word decides the family — `guid::is_player`). + const BROG: u64 = 0xF130_0000_9700_0001; + const DOBBINS: u64 = 0xF130_0001_D100_0002; + + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.add_systems(Update, feed_units); + // Two vendors, told apart by level alone — no name cache, no descriptor beyond it. + let mut vendor = |level: u32| { + app.world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(&[( + FIELD_UNIT_LEVEL, + level, + )]))) + .id() + }; + let brog = vendor(7); + let dobbins = vendor(9); + let eval = |app: &mut App, expr: &str| -> i64 { + app.world_mut() + .non_send_resource_mut::() + .eval::(expr) + .unwrap() + }; + let exists = |app: &mut App| eval(app, r#"return UnitExists("npc") and 1 or 0"#) == 1; + let level = |app: &mut App| eval(app, r#"return UnitLevel("npc")"#); + + // Nothing open: no token. + app.update(); + assert!(!exists(&mut app), "no window open, yet UnitExists(\"npc\")"); + + // Brog's window opens. + *app.world_mut().resource_mut::() = InteractNpc(Some(brog), Some(BROG)); + app.update(); + assert_eq!( + level(&mut app), + 7, + "the token names the vendor whose window opened" + ); + + // Dobbins' window opens OVER it: the interaction NPC is the only thing that moved this + // frame — no descriptor, no selection, no group — and the token must still follow. + *app.world_mut().resource_mut::() = InteractNpc(Some(dobbins), Some(DOBBINS)); + app.update(); + assert_eq!( + level(&mut app), + 9, + "a second vendor over an open window swaps the token" + ); + + // Closed: absence is data. + *app.world_mut().resource_mut::() = InteractNpc::default(); + app.update(); + assert!( + !exists(&mut app), + "the window closed, yet UnitExists(\"npc\")" + ); + } } diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index ab5f8916..e3c2fcea 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -373,7 +373,14 @@ fn is_instrument_consumer(rel: &str) -> bool { /// against it; a lane that owns rows in the table — the UI model tiles, sampling the cooldown's /// quadrant rotations off the pane's play head — has to write them in the table's encoding, /// not one of its own. One function, "encode this affine the way the table reads it". -const CEILING: usize = 176; +/// And 176 → 177: `mat_anim_table::MatAnimMirrors`, a PUBLISH — the mat-anim table's twin of +/// `instance_tint::InstanceTintMirrors` (decision 2023). A lane whose materials bind a light +/// buffer of their own reads the table out of THAT buffer, so the UI model tiles' rows — the +/// cooldown sweep's rotations, written every frame off the pane's play head — reached a region +/// nothing in a tile ever sampled until the tile's buffer was on a mirror list. Registered once +/// at the tile renderer's startup, the same shape and the same reason as the palette mirror +/// beside it. +const CEILING: usize = 177; /// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be /// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it diff --git a/crates/benilla-blp/src/lib.rs b/crates/benilla-blp/src/lib.rs index 1b3ea796..8660c55c 100644 --- a/crates/benilla-blp/src/lib.rs +++ b/crates/benilla-blp/src/lib.rs @@ -188,8 +188,10 @@ pub struct NativeMip { pub width: u32, pub height: u32, /// Block bytes (DXTC) or RGBA8 pixels — per [`NativeBlp::texels`]. Always exactly - /// [`BlpTexels::level_bytes`] long: a short tail level is zero-padded, matching what - /// [`decode`] does before handing blocks to the codec. + /// [`BlpTexels::level_bytes`] long: a sub-block tail level the header under-stores is + /// completed from the bytes that follow it in the file, as the reference copies it + /// (`Header::dxt_level_span`), and only a level the file ends inside is zero-padded — + /// matching what [`decode`] does before handing blocks to the codec. pub bytes: Vec, } @@ -279,6 +281,43 @@ impl Header<'_> { .ok_or(Error::OutOfBounds { level }) } + /// The bytes of DXTC level `level` **as the reference copies them** — `need` bytes from the + /// level's file offset, not the `sizes[level]` the header records — or `None` when the chain + /// ends early (same rule as [`Self::level_data`]). + /// + /// The two disagree exactly on the **sub-block tail**. The BLP encoder stores a level as + /// `max(1, (w/4)·(h/4))` blocks — ONE block for a 2×16 or a 1×8 level, where the block grid the + /// GPU decodes needs four and two. The reference never reads the header's size for a DXT level. + /// On its shipped Direct3D default the fill copies each level through the block copier + /// `0x5a5780`, `max(4,w)·max(4,h)` bytes flat from `fileImage + mipOffsets[level]`; on the + /// OpenGL arm it hands the upload that same pointer (`0x5a8555`, no copy) and `0x59f270` sizes + /// the read from the format alone, `bpp · max(4, wL) · max(4, hL) / 8` (`0x59f4e6`–`0x59f515`). + /// Either way the GPU gets the full block grid from the level's offset, so a short level is + /// completed with the bytes that FOLLOW it in the file: the next levels' own authored blocks + /// (wow-re `system/image/scratch/dxt-level-span-law.md`, both arms VERIFIED; decisions + /// 2020/2024). Padding the difference with + /// zeros instead (an all-zero BC block = colour black, alpha 0) is what turned the far rain + /// black: `RainDrop01.blp`'s levels 3 and 4 came out three-quarters and half black, and its + /// Mod2x lane reads no alpha (decision 2020, B358/B225). Only a level the FILE itself ends + /// inside is left short here, for [`pad_to`] to zero-fill — the reference reads past the file + /// into whatever its shared read buffer held before, which is nothing to be faithful to. + fn dxt_level_span<'b>( + &self, + bytes: &'b [u8], + level: usize, + need: usize, + ) -> Result> { + let Some(stored) = self.level_data(bytes, level)? else { + return Ok(None); + }; + if stored.len() >= need { + return Ok(Some(stored)); + } + let off = self.offsets[level] as usize; + let end = (off + need).min(bytes.len()); + Ok(Some(&bytes[off..end])) + } + /// Which block form this file's DXTC levels are in. `alpha_type` 0→DXT1, 1→DXT3, 7→DXT5; /// anything else (a stale byte — e.g. `2` on alpha-less particle atlases) → DXT1, since /// `alpha_bits` already governs whether alpha is meaningful. @@ -301,7 +340,15 @@ pub fn decode(bytes: &[u8]) -> Result { let mut mips = Vec::with_capacity(n); for level in 0..n { let (lw, lh) = level_size(h.width, h.height, level); - let Some(data) = h.level_data(bytes, level)? else { + // DXTC levels span what the reference copies (`dxt_level_span`), not what the header + // records — the two differ on the sub-block tail, and the difference was black. + let data = if h.compression == 2 { + let texels = h.dxt_texels(); + h.dxt_level_span(bytes, level, texels.level_bytes(lw, lh))? + } else { + h.level_data(bytes, level)? + }; + let Some(data) = data else { break; }; let rgba = match h.compression { @@ -344,12 +391,18 @@ pub fn decode_native(bytes: &[u8]) -> Result { let mut mips = Vec::with_capacity(n); for level in 0..n { let (lw, lh) = level_size(h.width, h.height, level); - let Some(data) = h.level_data(bytes, level)? else { + let need = texels.level_bytes(lw, lh); + let data = if h.compression == 2 { + h.dxt_level_span(bytes, level, need)? + } else { + h.level_data(bytes, level)? + }; + let Some(data) = data else { break; }; let bytes = match h.compression { 1 => decode_raw1(h.palette, data, lw, lh, h.alpha_bits)?, - 2 => pad_to(data, texels.level_bytes(lw, lh)), + 2 => pad_to(data, need), 3 => decode_raw3(data, lw, lh)?, other => return Err(Error::UnknownCompression(other)), }; @@ -387,8 +440,10 @@ pub fn decode_level(texels: BlpTexels, width: u32, height: u32, bytes: &[u8]) -> /// `data` grown to exactly `need` bytes with zeros (or truncated if the file over-stores). /// -/// The pad is what [`decode`] has always done before handing a short tail mip to the codec -/// (matching wow-blp / SereniaBLPLib); the native path owes the GPU the same full level. +/// After `Header::dxt_level_span` this only ever pads a level the FILE ends inside (a truncated +/// archive) — the sub-block tail the encoder under-stores is completed from the following levels +/// first, the way the reference does. Zero here is a BC block that decodes to black at alpha 0, +/// which is why the span comes first (decision 2020). fn pad_to(data: &[u8], need: usize) -> Vec { let mut out = vec![0u8; need]; let n = data.len().min(need); @@ -453,9 +508,9 @@ fn decode_raw3(data: &[u8], w: u32, h: u32) -> Result> { Ok(out) } -/// DXTC → RGBA8. The variant comes from [`Header::dxt_texels`]. Undersized small-mip data is -/// zero-padded to the block size (matching wow-blp / SereniaBLPLib), so a short tail mip still -/// decodes. +/// DXTC → RGBA8. The variant comes from [`Header::dxt_texels`]. `data` is the level's span as +/// the reference copies it (`Header::dxt_level_span`); anything still short of the block size — +/// a file that ends inside the level — is zero-padded so the level decodes. fn decode_dxt(data: &[u8], w: u32, h: u32, texels: BlpTexels) -> Result> { let fmt = texels.codec().expect("dxt_texels never returns Rgba8Unorm"); let blocks = pad_to(data, fmt.compressed_size(w as usize, h as usize)); @@ -631,21 +686,104 @@ mod tests { ); } - /// A tail level the file under-stores is zero-padded to a whole block, so the concatenated - /// chain a caller uploads is always exactly the size wgpu expects. + /// A level the header under-stores is completed from the bytes that FOLLOW it in the file — + /// the reference's upload (`0x59f4e6`–`0x59f515`: `bpp·max(4,w)·max(4,h)/8` bytes read from + /// the level's file offset) — so the concatenated chain a caller uploads is always exactly + /// the size wgpu expects, and it is the file's bytes, not zeros. Zeros were the bug: an + /// all-zero BC block is black. #[test] - fn a_short_tail_level_is_padded_to_its_full_block() { + fn a_short_level_is_completed_from_the_bytes_that_follow_it() { let mut b = dxt_blp(0, 8); - // Level 2 is the 2x2 tail — one whole BC1 block. Shrink its recorded size to 3 bytes. + // Level 2 is the 2x2 tail — one whole BC1 block. Shrink its recorded size to 3 bytes; the + // 1x1 level's 8 bytes follow it in the file. let short = 3u32; b[84 + 2 * 4..88 + 2 * 4].copy_from_slice(&short.to_le_bytes()); + let off = native_level_offset(&b, 2); let native = decode_native(&b).expect("short tail still decodes"); let tail = native.mips.last().unwrap(); assert_eq!((tail.width, tail.height), (2, 2)); assert_eq!(tail.bytes.len(), 8, "one whole BC1 block"); - assert!(tail.bytes[short as usize..].iter().all(|&x| x == 0)); - // And decode() survives the same truncation — the two paths pad identically. - assert_eq!(decode(&b).unwrap().mips.len(), native.mips.len()); + assert_eq!( + &tail.bytes[..], + &b[off..off + 8], + "the level is the file's next 8 bytes, the header's 3 and the 1x1's first 5" + ); + assert!( + tail.bytes[short as usize..].iter().any(|&x| x != 0), + "nothing was zero-filled — that was B358's black" + ); + // And decode() reads the same span — the two paths complete identically. + let decoded = decode(&b).unwrap(); + assert_eq!(decoded.mips.len(), native.mips.len()); + let mut out = vec![0u8; 2 * 2 * 4]; + texpresso::Format::Bc1.decompress(&tail.bytes, 2, 2, &mut out); + assert_eq!(out, decoded.mips.last().unwrap().rgba); + } + + /// **The shipped shape of the bug**, in miniature: a DXT3 texture taller than it is wide, + /// whose 2×8 level the encoder stores as ONE block (`max(1, (2/4)·(8/4))`) where the block + /// grid needs two. The reference uploads `8·max(4,2)·max(4,8)/8 = 32` bytes from that level's + /// offset — the stored block plus the 1×4 level's block after it. `RainDrop01.blp` is this at + /// 16×128: its 2×16 and 1×8 levels store 16 bytes each where the grid needs 64 and 32. + #[test] + fn a_sub_block_dxt3_level_spans_into_its_successors_as_the_reference_copies_it() { + // 4x16 DXT3: levels 4x16 (4 blocks), 2x8 (grid: 2 blocks; STORED: 1), 1x4 (1 block), + // 1x2 (1 block). mip_chain_count(4, 16, true) = 4, so all four are read. + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + let mut payload = Vec::new(); + let base = (HEADER_SIZE + PALETTE_SIZE) as u32; + for (i, (blocks_stored, fill)) in [(4usize, 0x10u8), (1, 0x20), (1, 0x30), (1, 0x40)] + .into_iter() + .enumerate() + { + offsets[i] = base + payload.len() as u32; + sizes[i] = (blocks_stored * 16) as u32; + payload.extend(std::iter::repeat_n(fill, blocks_stored * 16)); + } + let mut b = header(2, 8, 1, 1, 4, 16, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); + b.extend_from_slice(&payload); + + let native = decode_native(&b).expect("decodes"); + assert_eq!(native.texels, BlpTexels::Bc2); + assert_eq!(native.mips.len(), 4); + let l1 = &native.mips[1]; + assert_eq!((l1.width, l1.height), (2, 8)); + assert_eq!(l1.bytes.len(), 32, "two BC2 blocks for a 1x2 block grid"); + assert!( + l1.bytes[..16].iter().all(|&x| x == 0x20), + "first block: the level's own" + ); + assert!( + l1.bytes[16..].iter().all(|&x| x == 0x30), + "second block: the 1x4 level's, which follows it in the file — never zeros" + ); + // The levels that ARE a whole block are untouched by the rule. + assert!(native.mips[2].bytes.iter().all(|&x| x == 0x30)); + assert!(native.mips[3].bytes.iter().all(|&x| x == 0x40)); + // A short level the FILE ends inside is the one case left for the zero pad: the same + // texture authored with only its first two levels, the file ending right after the 2x8 + // level's single stored block — the completion has nothing to read. + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + offsets[0] = base; + sizes[0] = 64; + offsets[1] = base + 64; + sizes[1] = 16; + let mut b = header(2, 8, 1, 1, 4, 16, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); + b.extend(std::iter::repeat_n(0x10u8, 64)); + b.extend(std::iter::repeat_n(0x20u8, 16)); + let native = decode_native(&b).expect("still decodes what it has"); + assert_eq!(native.mips.len(), 2, "the chain ends where the file does"); + let l1 = &native.mips[1]; + assert_eq!(l1.bytes.len(), 32); + assert!(l1.bytes[..16].iter().all(|&x| x == 0x20)); + assert!( + l1.bytes[16..].iter().all(|&x| x == 0), + "past EOF there is only the pad" + ); } #[test] diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 74837351..7c1de280 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -57,6 +57,12 @@ enum Command { internal_path: String, /// Output `.png` file. output: PathBuf, + /// Also write every authored mip level (`.mip.png`) and print a per-level + /// texel census: how many texels the author left transparent vs not, the luma range of + /// each class, and how many sit below 128 — the set that DARKENS under a Mod2x lane, + /// which reads no alpha. The "what does the far sampler see" instrument (B358). + #[arg(long)] + mips: bool, }, /// Composite ONE character's body atlas off the chain and report what painted what: the /// equipment blits in blit order (with the file each region name resolved to, or `MISSING`), @@ -834,14 +840,41 @@ fn main() -> Result<()> { Command::Blp { internal_path, output, + mips, } => { let name = normalize(&internal_path); let data = chain .read_file(&name) .with_context(|| format!("reading '{name}' from chain"))?; - let (w, h) = benilla_formats::blp_to_png(&data, &output) - .with_context(|| format!("decoding BLP '{name}'"))?; - eprintln!("decoded {w}x{h} -> {}", output.display()); + if mips { + let stats = benilla_formats::blp_mips_to_png(&data, &output) + .with_context(|| format!("decoding BLP '{name}'"))?; + let luma = |l: Option<(u8, f32, u8)>| match l { + Some((lo, mean, hi)) => format!("{lo:>3}/{mean:>6.1}/{hi:>3}"), + None => " — ".to_string(), + }; + println!( + "level size outside(a=0) luma lo/mean/hi inside(a>0) luma lo/mean/hi below128" + ); + for s in &stats { + println!( + "{:>5} {:>4}x{:<4} {:>12} {:>14} {:>11} {:>14} {:>8}", + s.level, + s.width, + s.height, + s.outside, + luma(s.outside_luma), + s.inside, + luma(s.inside_luma), + s.below_128, + ); + } + eprintln!("wrote {} level(s) beside {}", stats.len(), output.display()); + } else { + let (w, h) = benilla_formats::blp_to_png(&data, &output) + .with_context(|| format!("decoding BLP '{name}'"))?; + eprintln!("decoded {w}x{h} -> {}", output.display()); + } } Command::Dbc { internal_path, diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f4f0e552..5bb7ecce 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -371,6 +371,95 @@ pub fn blp_to_png(blp_bytes: &[u8], out: &Path) -> Result<(u32, u32)> { Ok((w, h)) } +/// One authored mip level's texel census — what a sampler minifying onto this level actually +/// reads. Split by the alpha byte because the renderer's multiply lanes (Mod2x, `2·src·dst`) read +/// **no alpha**: a texel the author left transparent still modulates the scene by its colour, so +/// the "outside" colour of a cut-out is a look-bearing fact, not padding. +#[derive(Debug, Clone, PartialEq)] +pub struct BlpMipStats { + pub level: u32, + pub width: u32, + pub height: u32, + /// Texels with alpha 0 (the authored "outside") and with alpha > 0 (the "inside"). + pub outside: usize, + pub inside: usize, + /// `(min, mean, max)` of the RGB **luma** (`(r+g+b)/3`) over the outside / inside texels; + /// `None` when that class is empty. + pub outside_luma: Option<(u8, f32, u8)>, + pub inside_luma: Option<(u8, f32, u8)>, + /// Texels whose luma is below 128 — under Mod2x each of these DARKENS the scene. + pub below_128: usize, +} + +/// Census every authored mip level of an in-memory BLP (see [`BlpMipStats`]), decoding through the +/// same [`benilla_blp::decode`] every CPU consumer uses. The "what does the far sampler see" +/// instrument: a texture whose mip 0 reads neutral can still carry dark tail levels, and the tail +/// is all a minified streak or sprite ever samples. +pub fn blp_mip_stats(blp_bytes: &[u8]) -> Result> { + let decoded = + benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?; + Ok(decoded + .mips + .iter() + .enumerate() + .map(|(level, m)| { + let mut outside = (0usize, u8::MAX, 0u64, u8::MIN); + let mut inside = (0usize, u8::MAX, 0u64, u8::MIN); + let mut below_128 = 0usize; + for px in m.rgba.chunks_exact(4) { + let luma = ((px[0] as u32 + px[1] as u32 + px[2] as u32) / 3) as u8; + if luma < 128 { + below_128 += 1; + } + let acc = if px[3] == 0 { + &mut outside + } else { + &mut inside + }; + acc.0 += 1; + acc.1 = acc.1.min(luma); + acc.2 += luma as u64; + acc.3 = acc.3.max(luma); + } + let fold = |(n, lo, sum, hi): (usize, u8, u64, u8)| { + (n > 0).then(|| (lo, sum as f32 / n as f32, hi)) + }; + BlpMipStats { + level: level as u32, + width: m.width, + height: m.height, + outside: outside.0, + inside: inside.0, + outside_luma: fold(outside), + inside_luma: fold(inside), + below_128, + } + }) + .collect()) +} + +/// Write **every** authored mip level of a BLP as `.mip.png` beside `out` (whose own +/// path receives level 0, exactly as [`blp_to_png`] does), and return the per-level census. +pub fn blp_mips_to_png(blp_bytes: &[u8], out: &Path) -> Result> { + let decoded = + benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?; + let stem = out.with_extension(""); + for (level, m) in decoded.mips.iter().enumerate() { + let path = if level == 0 { + out.to_path_buf() + } else { + let mut p = stem.as_os_str().to_owned(); + p.push(format!(".mip{level}.png")); + std::path::PathBuf::from(p) + }; + image::RgbaImage::from_raw(m.width, m.height, m.rgba.clone()) + .ok_or_else(|| anyhow::anyhow!("BLP RGBA buffer size mismatch at level {level}"))? + .save(&path) + .with_context(|| format!("writing PNG {}", path.display()))?; + } + blp_mip_stats(blp_bytes) +} + /// Decode a BLP texture (raw bytes) to RGBA8: `(width, height, pixels)`. /// /// For feeding GPU textures (the renderer) without a disk round-trip; mip level 0. @@ -815,6 +904,62 @@ mod tests { assert_eq!(chain.mips[0].len(), 2 * 2 * 4); } + /// **B358 / B225, "black rain", pinned on the shipped asset.** `RainDrop01.blp` (16×128 + /// DXT3) under-stores its sub-block levels — 16 bytes for the 2×16 level where the block grid + /// needs 64, 16 for the 1×8 where it needs 32 — and a decoder that zero-fills the difference + /// makes those levels three-quarters and half BLACK (an all-zero BC2 block is colour 0 at + /// alpha 0). The rain streak draws that texture under Mod2x, which reads no alpha, so a far + /// streak minifying onto levels 3–4 multiplied the scene toward black. The reference completes + /// a short level from the bytes that follow it in the file (`0x5a5780`), which for this asset + /// are the next levels' own grey blocks. This is the census the fix was judged by: every level + /// the sampler can reach stays inside the texture's authored neutral band, and level 3's second + /// block IS level 4's first — the reference's copy, byte for byte. Skips without the client data. + #[test] + fn the_rain_streak_texture_is_neutral_on_every_level_the_sampler_reaches() { + let data = crate::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open chain"); + let path = "textures\\Weather\\RainDrop01.blp"; + let bytes = chain + .read_file(path) + .unwrap_or_else(|e| panic!("{path}: {e}")); + let native = blp_bytes_to_native_chain(&bytes).expect("decodes natively"); + assert_eq!((native.width, native.height), (16, 128)); + assert_eq!(native.texels, benilla_blp::BlpTexels::Bc2); + assert!( + native.mips.len() >= 5, + "the tail levels are what this is about" + ); + // Level 3 (2×16) is four blocks wide-grid; the file stores one. Blocks 2–4 are the + // following levels' — block 2 is level 4's own first block. + assert_eq!(native.mips[3].len(), 64); + assert_eq!( + &native.mips[3][16..32], + &native.mips[4][..16], + "level 3's second block must be the file's next 16 bytes — level 4's block" + ); + assert!( + native.mips[3][16..].iter().any(|&x| x != 0), + "level 3's completion is never zero-filled" + ); + // And the look-bearing fact, as the Mod2x lane reads it: no texel on any level darkens + // the scene by more than the authored grey does (luma ≥ 120; the authored band is 125–164). + let stats = blp_mip_stats(&bytes).expect("census"); + for s in &stats { + for (class, luma) in [("outside", s.outside_luma), ("inside", s.inside_luma)] { + if let Some((lo, _, _)) = luma { + assert!( + lo >= 120, + "level {} ({}x{}) {class} texels reach luma {lo} — a Mod2x streak sampling \ + this level darkens the scene (B358)", + s.level, + s.width, + s.height + ); + } + } + } + } + /// Every table the CSV dumper claims in its error hint really has a schema, and each dumps /// against the **shipped** file — which is the only check that matters, because `with_schema` /// rejects a field-count mismatch and `dbc_to_csv` would otherwise fail only when a session diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 059d9788..0bef3916 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -132,7 +132,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let rh = region_handle_of(lua, ®ion)?; let mut model = lua.app_data_mut::().expect("model"); let data = model.region_data.entry(rh).or_default(); - data.portrait_unit = Some(unit); + // CANONICAL (lowercase) on the way in, like `set_unit`'s map key: the binding + // resolves its token through the one resolver every `Unit*` binding shares + // (`0x519ef0` → `0x515970`), whose every compare is `_strnicmp` — so the stock + // `MerchantFrame.lua:68`, `GuildRegistrarFrame.lua:4` and `TradeFrame.lua:41` all + // write `"NPC"` and mean `"npc"`. The app samples the booth by this string + // (`portrait::PortraitImages`, keyed by the lowercase slot names), and a raw `"NPC"` + // reached it and matched nothing: an empty portrait ring on all three windows + // (decision 2022). + data.portrait_unit = Some(unit.to_ascii_lowercase()); data.texture = None; data.fill = None; data.circular = true; diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index 657633c7..f40dce6f 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -1200,3 +1200,37 @@ fn the_constructors_string_arguments_are_four_shapes_not_one() { constructor reads the result" ); } + +/// The token is CANONICAL on the way out — `"NPC"`, which the stock merchant, guild registrar +/// and trade windows all write (`MerchantFrame.lua:68`, `GuildRegistrarFrame.lua:4`, +/// `TradeFrame.lua:41`), binds as `"npc"`. The reference resolves every unit token +/// case-insensitively (`0x515970`'s ten compares are all `_strnicmp`), and the app samples the +/// booth by this exact string — a raw `"NPC"` matched no slot and left the ring empty on all +/// three windows (decision 2022). +#[test] +fn set_portrait_texture_folds_the_token_to_lowercase() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local f = CreateFrame("Frame", "NFrame") + f:SetPoint("TOPLEFT", 0, 0) + f:SetSize(100, 100) + local p = f:CreateTexture("NFramePortrait", "BACKGROUND") + p:SetSize(64, 64) + p:SetPoint("TOPLEFT", 0, 0) + SetPortraitTexture(p, "NPC") + "#, + ) + .unwrap(); + s.resolve(); + let bound = s.extract().into_iter().find_map(|q| match q.content { + QuadContent::Texture { + portrait_unit: Some(u), + .. + } => Some(u), + _ => None, + }); + assert_eq!(bound.as_deref(), Some("npc")); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-world/src/collision/one_sided.rs b/crates/benilla-world/src/collision/one_sided.rs index 1fd53f3f..208d3fcb 100644 --- a/crates/benilla-world/src/collision/one_sided.rs +++ b/crates/benilla-world/src/collision/one_sided.rs @@ -27,19 +27,32 @@ //! What is ported is the **law**, not the reference's resolver: the slide/step/snap machinery stays //! the standard kinematic controller (that direction was closed in 0207), contact normals for edge //! hits stay parry's, and convex (non-trimesh) colliders stay whole-shape — a convex volume has no -//! reachable backface. Depenetration — which the reference does not need (a one-sided swept world -//! never traps you) — keeps only its front-side half: a face may push the mover out along its -//! authored normal, never backwards through itself. The camera/LOS path is untouched *on purpose*: -//! the reference's segment kernel `0x7c29f0` is two-sided (0967), so avian's ordinary cast is -//! already faithful there. +//! reachable backface. The camera/LOS path is untouched *on purpose*: the reference's segment +//! kernel `0x7c29f0` is two-sided (0967), so avian's ordinary cast is already faithful there. +//! +//! **There is no depenetration pass, and that is the law too** (decision 2018). avian's +//! `move_and_slide` opens and closes with a Gauss–Seidel push-out of every face the shape overlaps; +//! the reference's resolver has no such stage anywhere in `0x634040`'s closure. Its world query is a +//! time-of-impact sweep and nothing else: a face the prism starts *behind* by more than 1/36 yd +//! (`[0x7ff9c8]`, the backface band in `0x632830`'s clip) is not a hit at all, one it straddles is a +//! hit at `t = 0` that the slide projects against, and neither ever *moves* a body that asked to go +//! nowhere. The push-out was benilla's, and it is what shoved a seated body sideways off its stool: +//! the server seats a player at a chair's origin, inside the chair's own collision box, and the +//! minimum-translation vector for a tall capsule inside a small box is horizontal — out through the +//! nearest side (B359, measured at 0.555 yd on the first frame, with no input). A body that starts +//! inside geometry leaves it the way the reference does: by walking out through the faces wound +//! away from it, which the sweep never counts. use avian3d::character_controller::move_and_slide::{ - DepenetrationConfig, MoveAndSlide, MoveAndSlideConfig, MoveAndSlideHitData, - MoveAndSlideHitResponse, MoveAndSlideOutput, MoveHitData, + MoveAndSlide, MoveAndSlideConfig, MoveAndSlideHitData, MoveAndSlideHitResponse, + MoveAndSlideOutput, MoveHitData, }; use avian3d::parry::bounding_volume::Aabb as ParryAabb; use avian3d::parry::math::Pose3; -use avian3d::parry::query::{cast_shapes, contact, Ray, RayCast, ShapeCastOptions}; +use avian3d::parry::query::{ + cast_shapes, contact, Ray, RayCast, ShapeCastOptions, ShapeCastStatus, +}; +use avian3d::parry::shape::Triangle; use avian3d::prelude::*; use bevy::prelude::*; use core::time::Duration; @@ -51,6 +64,23 @@ const FACING_EPS: f32 = f32::from_bits(0xb727_c5ac); /// avian's own stabilizer for `pull_back` when `n·dir` is nearly zero (`move_and_slide.rs`). const DOT_EPSILON: f32 = 0.005; +/// The reference's **backface band**: `[0x7ff9c8]` = `1/36` yd. A candidate face the mover has +/// already passed by more than this is not a hit — `0x632830`'s clip (wow-re +/// `resolve_clip.rs::polygon_toi`) rejects a face **every vertex of which** sits further than the +/// band behind the prism's leading plane along the motion, and keeps one with any vertex nearer +/// than that as a hit at `t = 0`. It is what makes the resolver a *sweep* and not a solver: a body +/// the server placed inside a hull (a seated player at a chair's origin, a caged creature) meets +/// faces it already straddles, and the band says which of those still count — a seat top half a +/// yard into the body, every vertex of it behind the body's leading edge, does not exist; a sloped +/// terrain triangle a few centimetres into the feet, one vertex of which still lies below them, +/// does (decision 2018). **Measured against the vertices, not the penetration depth**: the first +/// port used parry's contact depth, and on a 19° slope under a stool that read the terrain as +/// behind the feet — the body fell 26 yd through the world. Ported at this seam, like the facing +/// law, because it can only be applied where the *overlapping* triangle is known: avian's own +/// sweep skips every origin-penetrating hit (`ignore_origin_penetration`) and leaves the overlap to +/// the depenetration pass this module no longer runs. +const BACKFACE_BAND: f32 = 1.0 / 36.0; + /// One-sided drop-in for [`MoveAndSlide::cast_move`]: sweep `shape` (world-axis-aligned, as every /// mover capsule is) along `movement`, stopping `skin_width` short of the first face whose /// **authored winding opposes the motion**. Backfaces are not candidates at all — the sweep passes @@ -118,6 +148,13 @@ pub(crate) fn cast_move( ) else { continue; }; + // An overlap at `t = 0` is a hit only inside the band: a face the body has passed + // deeper than that is behind it, whatever its winding says about the motion. + if hit.status == ShapeCastStatus::PenetratingOrWithinTargetDist + && behind_the_band(&tri, &local_pose, shape, local_dir) + { + continue; + } if hit.time_of_impact < best_toi { best_toi = hit.time_of_impact; best = Some(MoveHitData { @@ -248,9 +285,11 @@ pub(crate) fn cast_ray( best } -/// One-sided drop-in for [`MoveAndSlide::move_and_slide`]: avian's algorithm — depenetrate, sweep, -/// collect contact planes, project velocity, repeat, depenetrate — with every stage running over the -/// gated candidate set instead of the two-sided trimesh. The `on_hit` callback contract is avian's +/// One-sided drop-in for [`MoveAndSlide::move_and_slide`]: avian's algorithm — sweep, collect +/// contact planes, project velocity, repeat — with every stage running over the gated candidate set +/// instead of the two-sided trimesh, and **without** avian's opening and closing depenetration +/// (see the module note: the reference's resolver has no push-out stage, and the one this carried +/// is what displaced a seated body out of its chair). The `on_hit` callback contract is avian's /// ([`MoveAndSlideHitData`] / [`MoveAndSlideHitResponse`]), so the mover's ride/steep-wall handlers /// move over unchanged. #[allow(clippy::too_many_arguments)] @@ -267,9 +306,6 @@ pub(crate) fn move_and_slide( let mut position = shape_position; let mut time_left = delta_time.as_secs_f32(); let skin_width = ms.length_unit.0 * config.skin_width; - let depen_config: DepenetrationConfig = config.into(); - - position += depenetrate(ms, shape, position, &depen_config, filter); for _ in 0..config.move_and_slide_iterations { let sweep = time_left * velocity; @@ -317,7 +353,7 @@ pub(crate) fn move_and_slide( position, skin_width * 2.0, filter, - Some(velocity), + velocity, |entity, point, normal, _dist| { let mut normal = Dir3::new_unchecked(normal); // Prune nearly-parallel planes, keeping the most blocking version (avian's rule). @@ -362,52 +398,17 @@ pub(crate) fn move_and_slide( } } - position += depenetrate(ms, shape, position, &depen_config, filter); - MoveAndSlideOutput { position, projected_velocity: velocity, } } -/// One-sided depenetration: gather front-side contacts within the skin and hand them to avian's own -/// Gauss–Seidel solver ([`MoveAndSlide::depenetrate_intersections`]). A backface contact is dropped — -/// a face never ejects a body backwards through itself, which is what lets you *leave* an inward-wound -/// hull the way the reference never entered it. -fn depenetrate( - ms: &MoveAndSlide<'_, '_>, - shape: &Collider, - position: Vec3, - config: &DepenetrationConfig, - filter: &SpatialQueryFilter, -) -> Vec3 { - if config.depenetration_iterations == 0 { - return Vec3::ZERO; - } - let skin_width = ms.length_unit.0 * config.skin_width; - let mut intersections = Vec::new(); - for_each_contact( - ms, - shape, - position, - skin_width, - filter, - None, - |_, _, normal, dist| { - // avian pushes (normal, penetration + skin); parry's `dist` is negative when penetrating. - intersections.push((Dir3::new_unchecked(normal), -dist + skin_width)); - true - }, - ); - ms.depenetrate_intersections(config, &intersections) -} - /// Visit every contact of `shape` (world-axis-aligned) against the filtered world within -/// `prediction`, gated by the one-sided law. `motion` selects the gate: -/// -/// - `Some(velocity)` — the slide's plane collection: a triangle participates iff its authored -/// normal opposes the velocity (`n·v̂ ≤ EPS`) *and* the mover sits on its front side. -/// - `None` — depenetration: the front-side test alone (no motion exists yet to oppose). +/// `prediction`, gated by the one-sided law with `motion` as the direction being resolved — the +/// slide's plane collection: a triangle participates iff its authored normal opposes the velocity +/// (`n·v̂ ≤ EPS`) *and* the mover sits on its front side (behind a face, its plane does not exist +/// for movement). /// /// The callback receives `(entity, world contact point, world contact normal toward the mover, /// contact distance — negative when penetrating)` and returns `false` to stop the visit. Convex @@ -418,13 +419,12 @@ fn for_each_contact( position: Vec3, prediction: f32, filter: &SpatialQueryFilter, - motion: Option, + motion: Vec3, mut callback: impl FnMut(Entity, Vec3, Vec3, f32) -> bool, ) { - let motion_dir = motion.and_then(|v| Dir3::new(v).ok()); - if motion.is_some() && motion_dir.is_none() { + let Ok(motion_dir) = Dir3::new(motion) else { return; // a zero velocity has nothing to clip - } + }; let a = shape.aabb(position, Quat::IDENTITY); let grown = ColliderAabb { min: a.min - Vec3::splat(prediction), @@ -441,7 +441,7 @@ fn for_each_contact( if let Some(trimesh) = collider.shape_scaled().as_trimesh() { let inv_rot = rot.0.inverse(); let local_pose = Pose3::from_parts(inv_rot * (position - pos.0), inv_rot); - let local_motion = motion_dir.map(|d| inv_rot * *d); + let local_dir = inv_rot * *motion_dir; for tri_id in trimesh .bvh() .intersect_aabb(&aabb_to_local(grown, pos.0, inv_rot)) @@ -450,10 +450,8 @@ fn for_each_contact( let Some(n) = tri.normal() else { continue; }; - if let Some(local_dir) = local_motion { - if n.dot(local_dir) > FACING_EPS { - continue; - } + if n.dot(local_dir) > FACING_EPS { + continue; } let Ok(Some(c)) = contact( &Pose3::IDENTITY, @@ -469,6 +467,13 @@ fn for_each_contact( if c.normal1.dot(n) <= 0.0 { continue; } + // The backface band (see [`BACKFACE_BAND`]): a face the body has already passed + // is not a plane to slide along — the sweep does not count it, and a slide plane + // the sweep never reported would clip the body against geometry it is meant to + // walk straight out of. + if c.dist < 0.0 && behind_the_band(&tri, &local_pose, shape, local_dir) { + continue; + } if !callback(entity, pos.0 + rot.0 * c.point1, rot.0 * c.normal1, c.dist) { break 'outer; } @@ -490,6 +495,23 @@ fn for_each_contact( } } +/// Has the mover already passed this triangle by more than the [`BACKFACE_BAND`]? The reference's +/// own measure: every vertex of the face against the mover's **leading extent along the motion** +/// (`0x632830`'s per-vertex `ray_plane_t` against the prism's leading plane). The leading extent is +/// the shape's support point along `dir` — for the capsule, the far segment end plus the radius. +/// A shape with no support map (none on the movement audience) is never behind: the hit stands. +fn behind_the_band(tri: &Triangle, shape_pose: &Pose3, shape: &Collider, dir: Vec3) -> bool { + let Some(support) = shape.shape_scaled().as_support_map() else { + return false; + }; + let lead = support.support_point(shape_pose, dir).dot(dir); + let ahead = [tri.a, tri.b, tri.c] + .into_iter() + .map(|v| v.dot(dir) - lead) + .fold(f32::MIN, f32::max); + ahead < -BACKFACE_BAND +} + /// The conservative local-frame box of a world AABB under `world→local` = `inv_rot · (x − pos)`: /// transform the eight corners and take their extent. fn aabb_to_local(aabb: ColliderAabb, pos: Vec3, inv_rot: Quat) -> ParryAabb { @@ -629,6 +651,117 @@ mod tests { Collider::capsule(0.4, 1.0) } + /// A headless world holding one up-wound trimesh built from `(verts, tris)`. + fn world_with_mesh(verts: Vec, tris: Vec<[u32; 3]>) -> App { + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + bevy::transform::TransformPlugin, + bevy::asset::AssetPlugin::default(), + bevy::scene::ScenePlugin, + PhysicsPlugins::new(bevy::app::PostUpdate), + )); + app.init_asset::(); + app.world_mut().spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform::default(), + )); + app.finish(); + app.cleanup(); + app.update(); + app + } + + /// Sweep the test capsule from `from` by `movement` and report the hit distance, `None` on a miss. + fn sweep(app: &mut App, from: Vec3, movement: Vec3) -> Option { + app.world_mut() + .run_system_once(move |ms: MoveAndSlide| { + cast_move( + &ms, + &capsule(), + from, + movement, + 0.02, + &SpatialQueryFilter::default(), + ) + .map(|h| h.collision_distance) + }) + .unwrap() + } + + /// **The backface band, both faces of it** (decision 2018). The capsule here is 1.8 tall + /// (radius 0.4, segment 1.0), its centre 0.9 above its feet. + /// + /// A small horizontal face the body straddles — a seat top 0.5 yd into it, every vertex behind + /// the body's leading edge along a horizontal walk — is not a hit: the body walks out of the + /// chair. A large **sloped** face the feet sit 5 cm under — the terrain under a stool — is + /// still a hit for a downward probe, because a vertex of it lies below the feet; the first port + /// measured parry's penetration depth instead and dropped the floor, and the body fell through + /// the world. And a **flat** face every vertex of which is more than the band above the feet + /// is gone, which is the reference's own answer for a body placed under flat ground. + #[test] + fn the_band_keeps_a_straddled_slope_and_drops_a_passed_face() { + // A 0.6 × 0.6 seat top at y = 0.5, up-wound; the body's feet at y = 0 on its centre. + let seat = world_with_mesh( + vec![ + Vec3::new(-0.3, 0.5, -0.3), + Vec3::new(0.3, 0.5, -0.3), + Vec3::new(0.3, 0.5, 0.3), + Vec3::new(-0.3, 0.5, 0.3), + ], + vec![[0u32, 2, 1], [0, 3, 2]], + ); + let centre = Vec3::new(0.0, 0.9, 0.0); + let mut seat = seat; + assert_eq!( + sweep(&mut seat, centre, Vec3::X * 0.2), + None, + "a seat top the body straddles is behind its leading edge: the walk out is free" + ); + + // A 10-yd terrain triangle rising 1 yd across its span (a ~6° slope), the feet 5 cm + // under its plane at the body's own x — but one vertex of it lies well below the feet. + let mut slope = world_with_mesh( + vec![ + Vec3::new(-5.0, -0.5, -5.0), + Vec3::new(5.0, 0.5, -5.0), + Vec3::new(5.0, 0.5, 5.0), + Vec3::new(-5.0, -0.5, 5.0), + ], + vec![[0u32, 2, 1], [0, 3, 2]], + ); + let feet_under = Vec3::new(0.0, 0.9 - 0.05, 0.0); + assert_eq!( + sweep(&mut slope, feet_under, Vec3::NEG_Y * 0.2), + Some(0.0), + "a sloped floor the feet sit 5 cm under still supports: a vertex of it is below them" + ); + + // The same feet 5 cm under a FLAT floor: every vertex more than the band behind — gone. + let mut flat = world_with_mesh( + vec![ + Vec3::new(-5.0, 0.0, -5.0), + Vec3::new(5.0, 0.0, -5.0), + Vec3::new(5.0, 0.0, 5.0), + Vec3::new(-5.0, 0.0, 5.0), + ], + vec![[0u32, 2, 1], [0, 3, 2]], + ); + assert_eq!( + sweep(&mut flat, feet_under, Vec3::NEG_Y * 0.2), + None, + "a flat floor more than 1/36 yd above the feet has been passed" + ); + // …and 2 cm under it — inside the band — is still standing on it. + let feet_just_under = Vec3::new(0.0, 0.9 - 0.02, 0.0); + assert_eq!( + sweep(&mut flat, feet_just_under, Vec3::NEG_Y * 0.2), + Some(0.0), + "a floor within the band is a hit at t = 0" + ); + } + #[test] fn a_floor_blocks_a_fall_and_a_backface_does_not() { // The whole bug in one assertion pair: the same quad, same cast, opposite winding. @@ -690,7 +823,7 @@ mod tests { #[test] fn the_slide_falls_through_a_backface_and_rests_on_a_floor() { // The full resolve, not just the cast: one second of straight fall through/onto the quad. - // Backface: every stage — sweep, contact planes, depenetration — must let the body pass. + // Backface: every stage — sweep and contact planes — must let the body pass. for (up, expect_above) in [(true, true), (false, false)] { let end = world_with_quad(up) .world_mut() diff --git a/crates/benilla-world/src/mat_anim_table.rs b/crates/benilla-world/src/mat_anim_table.rs index 18e168ba..e70c01fe 100644 --- a/crates/benilla-world/src/mat_anim_table.rs +++ b/crates/benilla-world/src/mat_anim_table.rs @@ -56,6 +56,22 @@ pub(crate) fn region_bytes() -> u64 { (MAX_MAT_ANIM_SLOTS * 16) as u64 } +/// Off-world `wow_light`-layout buffers that also carry the mat-anim region — registered by key, +/// the shape of [`crate::instance_tint::InstanceTintMirrors`] and, like it, deliberately its own +/// list. +/// +/// A lane whose materials bind a light buffer of their own — the UI model tiles, whose twins +/// carry the widget's black light — reads `matanim[slot]` out of THAT buffer, so rows it writes +/// into the table reach it only if the table is uploaded there too (decision 2023: the cooldown +/// sweep's rotation rows were written every frame into a region only the world's materials ever +/// sampled, and the tile read the zero-initialised identity). The portrait booths are not on it: +/// a bake stands in for a world instance whose animated materials keep the world's rows, and the +/// studio buffers' zeroed region is the seed pose those bakes were built to show. +#[derive(Resource, Clone, Default, ExtractResource)] +pub struct MatAnimMirrors( + pub std::collections::HashMap<&'static str, bevy::render::render_resource::Buffer>, +); + /// The live delta table. `Arc`-shared so the render-world extract is a pointer bump, and /// generation-stamped so a scene with nothing animated in view uploads nothing at all /// ([`crate::instance_tint`]'s pattern, verbatim). @@ -96,6 +112,12 @@ impl MatAnimTable { } } + /// Read slot `slot`'s row as last written (the identity row for an out-of-range slot) — the + /// tile probe's view of what a lane wrote. + pub fn row(&self, slot: u16) -> [f32; 4] { + self.rows.get(slot as usize).copied().unwrap_or([0.0; 4]) + } + /// Free a slot when its registry entry dies (the material was unloaded): the row zeroes — /// back to identity — so the next allocation can never inherit a dead batch's delta. pub fn free(&mut self, slot: u16) { @@ -138,26 +160,33 @@ pub fn affine_row(q: [f32; 4], scale: [f32; 2]) -> [f32; 4] { fn upload_mat_anim( queue: Res, shared: Option>, + mirrors: Option>, table: Option>, mut last: Local>, ) { - let (Some(shared), Some(table)) = (shared, table) else { - return; - }; - if *last == Some(table.generation) { + let Some(table) = table else { return }; + // The generation gate covers the mirror list too (the tint table's rule): a mirror + // registered after the last write would otherwise hold the zero region until the next + // animated frame. One comparison closes the hole by construction. + let mirrors = mirrors + .map(|m| m.0.values().cloned().collect::>()) + .unwrap_or_default(); + let gate = table.generation ^ ((mirrors.len() as u64) << 40); + if *last == Some(gate) { return; } - *last = Some(table.generation); - queue.write_buffer( - &shared.0, - region_offset(), - bytemuck::cast_slice(table.rows.as_slice()), - ); + *last = Some(gate); + let rows = bytemuck::cast_slice(table.rows.as_slice()); + for buffer in shared.iter().map(|s| &s.0).chain(mirrors.iter()) { + queue.write_buffer(buffer, region_offset(), rows); + } } pub fn plugin(app: &mut App) { app.init_resource::() - .add_plugins(ExtractResourcePlugin::::default()); + .init_resource::() + .add_plugins(ExtractResourcePlugin::::default()) + .add_plugins(ExtractResourcePlugin::::default()); if let Some(render) = app.get_sub_app_mut(RenderApp) { render.add_systems( Render,