the far rain stops going black, and a seated body stays on its seat

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:36:25 +07:00
co-authored by Claude Fable 5.1
parent c3555b4eea
commit 6cab3e9380
27 changed files with 2334 additions and 243 deletions
+2
View File
@@ -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,
@@ -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`,
@@ -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: <leg> 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 `<General Supplies>` — `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::<VendorSwapProbe>()
.add_systems(Update, vendor_swap_probe);
}
}
#[derive(Resource, Default)]
struct VendorSwapProbe {
phase: Phase,
passes: u32,
fails: u32,
skips: u32,
brog: Option<u64>,
dobbins: Option<u64>,
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<f64>,
},
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<String> = 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<VendorSwapProbe>,
merchant: Res<MerchantOpen>,
script: Option<NonSendMut<UiScript>>,
mut names: ResMut<NameCache>,
panes: Res<BoothPanes>,
images: Res<PortraitImages>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
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<u64>| 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);
});
}
}
}
+8
View File
@@ -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
+10 -8
View File
@@ -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<SessionEvent>);
pub(crate) struct NetEvents(Receiver<SessionEvent>);
/// The outbound command channel — cloned by the player/chat systems to send movement + chat.
#[derive(Resource)]
+1 -1
View File
@@ -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<NetEvents>,
mut index: ResMut<GuidIndex>,
@@ -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 {
+569 -46
View File
@@ -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<benilla_world::collision::ColliderEpoch>,
// 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<crate::player::PlayerCapsule>,
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=<display id>`): 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=<display id>`); `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<u32> {
static ID: std::sync::OnceLock<Option<u32>> = 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<bool> = 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::<Transform>(npc)
@@ -1724,6 +1875,11 @@ mod server_moved_players {
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
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<Vec3> = (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::<benilla_world::collision::MoverTraceExclusions>();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
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::<Transform>(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::<GroundClamped>(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::<Transform>(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::<Transform>(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<Vec3> = 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::<Transform>(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::<ColliderEpoch>().bump();
app.update();
let mut t = app.world_mut().get_mut::<Transform>(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::<Transform>(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)
);
}
}
+6 -6
View File
@@ -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
+20 -3
View File
@@ -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;
}
{
+19 -3
View File
@@ -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
),
);
}
+43 -11
View File
@@ -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;
+77 -1
View File
@@ -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)
+1 -1
View File
@@ -196,7 +196,7 @@ pub(super) fn watch(
let rungs: Vec<String> = 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".
+223 -9
View File
@@ -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<bool> =
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<String>,
/// 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<Rect>,
}
/// 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<RenderDevice>,
queue: Res<RenderQueue>,
mut mirrors: ResMut<RigPaletteMirrors>,
mut anim_mirrors: ResMut<MatAnimMirrors>,
) {
// `<Model>`'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<f32> = 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<String> = 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<UiModelTiles>, mut quads: ResMut<UiQuads>) {
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<UiQuad> {
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<UiQuad> = 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]
+23 -20
View File
@@ -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("<anonymous>"),
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;
}
@@ -452,9 +452,14 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
/// **The ping's pixels are the stock `<Model>`'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::<UiQuads>()
.quads
let quads = app.world().resource::<UiQuads>();
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::<Vec<_>>()
};
let next_frame = |app: &mut App| {
app.world_mut().resource_mut::<UiQuads>().overlays.clear();
app.update();
};
{
let tiles = app.world().resource::<UiModelTiles>();
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::<UiScript>();
// 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::<UiScript>();
// 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");
}
+103 -9
View File
@@ -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::<InteractNpc>()
.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::<InteractNpc>().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<T: NpcSession>(
/// 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<Entity>, pub(crate) Option<u64>);
/// 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::<Schedules>`: 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"
);
});
}
}
+104 -2
View File
@@ -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::<UnitFeedState>()
.init_resource::<Selection>()
.init_resource::<NameCache>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<ChatLog>()
.init_resource::<crate::ui_guild::GuildState>()
.init_resource::<InteractNpc>();
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::<UiScript>()
.eval::<i64>(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>() = 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>() = 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>() = InteractNpc::default();
app.update();
assert!(
!exists(&mut app),
"the window closed, yet UnitExists(\"npc\")"
);
}
}
+8 -1
View File
@@ -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
+155 -17
View File
@@ -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<u8>,
}
@@ -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<Option<&'b [u8]>> {
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<DecodedBlp> {
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<NativeBlp> {
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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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]
@@ -57,6 +57,12 @@ enum Command {
internal_path: String,
/// Output `.png` file.
output: PathBuf,
/// Also write every authored mip level (`<stem>.mip<N>.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,
+145
View File
@@ -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<Vec<BlpMipStats>> {
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 `<stem>.mip<N>.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<Vec<BlpMipStats>> {
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
+9 -1
View File
@@ -132,7 +132,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().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;
@@ -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());
}
+197 -64
View File
@@ -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<Vec3>,
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<Vec3>, 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::<Mesh>();
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<f32> {
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()
+40 -11
View File
@@ -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<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
mirrors: Option<Res<MatAnimMirrors>>,
table: Option<Res<MatAnimTable>>,
mut last: Local<Option<u64>>,
) {
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::<Vec<_>>())
.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::<MatAnimTable>()
.add_plugins(ExtractResourcePlugin::<MatAnimTable>::default());
.init_resource::<MatAnimMirrors>()
.add_plugins(ExtractResourcePlugin::<MatAnimTable>::default())
.add_plugins(ExtractResourcePlugin::<MatAnimMirrors>::default());
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,