walkers keep walking, and dungeon entrances stop dropping you

- A creature that was already walking when it came into view stood frozen at
  its spawn point, then teleported — up to a full wander leg, 30 yards in the
  worst measured case — when its next movement packet arrived. The create
  packet carries the live path (where the unit is along it, how much is left)
  and we parsed all of it into the void. The unit now joins its walk exactly
  where the server has it: measured before/after, 13% of movement starts were
  over-a-yard teleports, now effectively zero.
- Walking into Zul'Gurub or Stratholme through the portal dropped you straight
  through the world. The landing check asks "is there a floor under me?" — but
  on the arrival frame the loaded world is still the map you just left, and an
  instance entrance is a copy of its outdoor ground at the same coordinates,
  0.23 yards under your feet. The old floor said yes, then despawned. The hold
  now waits for the destination's own ground to arrive, and a world that never
  arrives keeps the loading screen instead of dropping you into the void.
- Same fix covers a decoder hazard found on the way: a plain two-point path
  announces two points but ships only the destination, and trusting the count
  over-ran the packet — it never fires on our server's pathfinder, but now it
  can't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-27 19:38:01 +07:00
co-authored by Claude Fable 5
parent 1531628a28
commit fd54ea9197
29 changed files with 1371 additions and 109 deletions
@@ -0,0 +1,76 @@
//! Where an area trigger actually **is**, and how to walk into it:
//! `cargo run -p benilla-formats --example area_trigger_at -- <id|map> [id|map…]`
//!
//! The client's half of every portal is pure geometry (`crate::area_trigger`'s law), and that
//! geometry lives only in `AreaTrigger.dbc` — the server's `areatrigger_teleport` row names the
//! *destination* but never the volume you have to cross to reach it. So a probe that wants to
//! reproduce a portal the way a player meets it — **walking in**, which the module's own warning
//! says is not the same as a `.go` that lands inside — has no way to aim without this.
//!
//! Prints each row's volume (sphere radius, or the oriented box) plus a ready-made `.go` for a spot
//! `APPROACH` yards **outside** it on the box's own axis, and the facing that then walks you
//! through. An argument about whether a portal fired is otherwise unfalsifiable: landing inside the
//! volume races the server's re-check and is silently ignored about one time in six (measured, see
//! `crate::area_trigger`).
//!
//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo.
/// How far outside the volume to park the approach `.go`, in yards. Far enough that the trigger is
/// unambiguously *not* yet entered (so the walk-in is a real crossing), close enough that a few
/// seconds of held-forward covers it.
const APPROACH: f32 = 12.0;
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
anyhow::bail!("usage: area_trigger_at <id|map:N>...");
}
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let cat = benilla_formats::load_area_trigger_catalog(&mut chain)?;
println!("AreaTrigger.dbc — {} rows\n", cat.len());
for arg in &args {
let rows: Vec<_> = if let Some(m) = arg.strip_prefix("map:") {
cat.on_map(m.parse()?).iter().collect()
} else {
cat.get(arg.parse()?).into_iter().collect()
};
if rows.is_empty() {
println!("no rows for {arg:?}\n");
continue;
}
for r in rows {
let [x, y, z] = r.position;
if r.radius > 0.0 {
println!(
"id {:5} map {:3} SPHERE r={:.1} at ({x:.2}, {y:.2}, {z:.2})",
r.id, r.map_id, r.radius
);
// A sphere has no authored axis; approach along -Y, the arbitrary but stated choice.
let sy = y + r.radius + APPROACH;
println!(
" walk in: .go xyz {x:.2} {sy:.2} {z:.2} {} then face -Y (yaw {:.3}) and hold forward",
r.map_id,
std::f32::consts::FRAC_PI_2 * 3.0,
);
} else {
let [bx, by, bz] = r.box_size;
println!(
"id {:5} map {:3} BOX {bx:.1}x{by:.1}x{bz:.1} yaw {:.3} at ({x:.2}, {y:.2}, {z:.2})",
r.id, r.map_id, r.box_yaw
);
// Approach along the box's local +X axis, backing off half its depth plus the margin.
let (s, c) = r.box_yaw.sin_cos();
let back = bx * 0.5 + APPROACH;
let (sx, sy) = (x - c * back, y - s * back);
println!(
" walk in: .go xyz {sx:.2} {sy:.2} {z:.2} {} then face yaw {:.3} and hold forward",
r.map_id, r.box_yaw
);
}
}
println!();
}
Ok(())
}
@@ -0,0 +1,137 @@
//! What a WMO's **collision** actually costs, and whether its walking gather has a floor at all:
//! `cargo run -p benilla-formats --example wmo_collision_cost -- <wmo-path-or-substring>...`
//! e.g. `wmo_collision_cost stratholme.wmo zulgurubcity`.
//!
//! The falsifier for a **fall-through** report, which has exactly two shapes and they take opposite
//! fixes:
//!
//! * **No floor to stand on** — the walking gather (`flags & 0x04` DETAIL dropped) came back with no
//! upward-facing faces in a group the camera gather *does* see. Then the collider is present and
//! correct and still cannot hold you: the authored floor is flagged decal-only. A data fact, fixed
//! by widening the gather, and visible here without entering the game.
//! * **A floor that arrives too late** — the faces are there, but the placement's trimesh is big
//! enough that its off-thread build plus the per-frame attach budget runs past the mover's settle
//! timeout, so gravity switches on over geometry that is not solid yet. A *timing* fact; this
//! prints the triangle count that drives it, so the margin can be compared against
//! `SETTLE_TIMEOUT` without guessing which buildings are the heavy ones.
//!
//! Both readings are properties of the shipped file, so read them here rather than inferring them
//! from a fall you may or may not be able to reproduce on your own machine — the timing shape
//! reproduces only on a machine slow enough, which is exactly why it reaches players and not us.
//!
//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo.
use benilla_formats::{accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision};
/// Faces at least this upward-facing count as a **floor** — the mover's own walkable test
/// (`GROUND_COS`, ~50° from vertical). A group with collidable faces but none of them walkable is
/// a wall/ceiling shell you slide down, not something you can stand on.
const WALKABLE_COS: f32 = 0.64;
/// One gather's shape: how many triangles, and how many of them you could stand on.
struct Gather {
tris: usize,
walkable: usize,
min_z: f32,
max_z: f32,
}
fn gather(positions: &[[f32; 3]], indices: &[u32]) -> Gather {
let (mut walkable, mut min_z, mut max_z) = (0usize, f32::MAX, f32::MIN);
for t in indices.chunks_exact(3) {
let p: [[f32; 3]; 3] = [
positions[t[0] as usize],
positions[t[1] as usize],
positions[t[2] as usize],
];
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
let v = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
// Raw WMO space is Z-up, so the walkable axis is the normal's Z.
let n = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if len > 0.0 && (n[2] / len).abs() >= WALKABLE_COS {
walkable += 1;
}
for c in p {
min_z = min_z.min(c[2]);
max_z = max_z.max(c[2]);
}
}
Gather {
tris: indices.len() / 3,
walkable,
min_z,
max_z,
}
}
fn main() -> anyhow::Result<()> {
let pats: Vec<String> = std::env::args().skip(1).collect();
if pats.is_empty() {
anyhow::bail!("usage: wmo_collision_cost <wmo-path-or-substring>...");
}
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let names: Vec<String> = chain.list()?.into_iter().map(|e| e.name).collect();
for pat in &pats {
let lower = pat.to_lowercase();
let path = if lower.ends_with(".wmo") && chain.contains(pat) {
pat.clone()
} else {
let Some(found) = names.iter().find(|n| {
let l = n.to_lowercase();
l.ends_with(".wmo")
&& l.contains(&lower)
&& !l.rsplit_once('_').is_some_and(|(_, tail)| {
tail.len() == 7 && tail.starts_with(|c: char| c.is_ascii_digit())
})
}) else {
println!("no root .wmo matching {pat:?}\n");
continue;
};
found.clone()
};
let bytes = chain.read_file(&path.to_ascii_lowercase())?;
let n_groups = benilla_formats::parse_wmo_root(&bytes)?.group_count();
let stem = {
let l = path.to_ascii_lowercase();
l.strip_suffix(".wmo").unwrap_or(&l).to_string()
};
println!("=== {path} — {n_groups} group(s) ===");
let (mut tot_walk, mut tot_cam, mut floorless) = (0usize, 0usize, Vec::new());
for gi in 0..n_groups {
let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
let (mut wp, mut wi) = (Vec::new(), Vec::new());
accumulate_wmo_group_collision(&gbytes, &mut wp, &mut wi);
let (mut cp, mut ci) = (Vec::new(), Vec::new());
accumulate_wmo_group_camera_collision(&gbytes, &mut cp, &mut ci);
let (w, c) = (gather(&wp, &wi), gather(&cp, &ci));
tot_walk += w.tris;
tot_cam += c.tris;
// The defect shape: the camera can stand on this group, the player body cannot.
if w.walkable == 0 && c.walkable > 0 {
floorless.push(gi);
println!(
" group {gi:3}: walk {:6} tri ({:5} walkable) camera {:6} tri ({:5} walkable) \
<-- NO WALKABLE FACE IN THE WALK GATHER (z {:.1}..{:.1})",
w.tris, w.walkable, c.tris, c.walkable, c.min_z, c.max_z
);
}
}
println!(
" TOTAL walk {tot_walk} tri, camera {tot_cam} tri \
({} group(s) with a camera-only floor)\n",
floorless.len()
);
}
Ok(())
}
+7 -1
View File
@@ -10,7 +10,7 @@
//! Coordinates stay **raw WoW** (the `benilla` boundary applies `bevy = (-y, z, -x)`).
use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, DamageShield,
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, FriendEntry, FriendStatusUpdate, GossipOption, GroupLootInfo,
GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MonsterMoveFacing, ObjectFields,
@@ -157,6 +157,12 @@ pub enum SessionEvent {
/// `Some` when a freshly-created unit/player is standing on a boat/zeppelin/elevator at create
/// time, local to that transport's frame (decision 0438).
transport: Option<TransportPose>,
/// The path this unit was **already walking** when it streamed into view
/// (`MOVEFLAG_SPLINE_ENABLED` in its `LIVING` block) — the same travel-order polyline as
/// [`Self::MonsterMove`], plus the milliseconds of it the server has already ridden, so the app
/// can join the walk mid-path. Dropping it is what froze every creature that happened to be in
/// motion at first sight, until its next `MonsterMove` teleported it forward (decision 0708).
spline: Option<CreateSpline>,
/// The object's full descriptor field set from the create mask — health/level/appearance/
/// customization and everything else. The ECS seeds its `ObjectStore` from this, then merges
/// later [`Self::ObjectValues`] deltas into it (decision 0061).
@@ -785,6 +785,7 @@ fn decode_objects(objects: Vec<Object>) -> Vec<SessionEvent> {
speeds,
transport_progress: movement.transport_progress,
transport: movement.transport,
spline: movement.spline,
fields: mask,
});
}
+3 -3
View File
@@ -15,9 +15,9 @@ pub mod world;
pub use auth::AuthReject;
pub use events::{decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEvent};
pub use messages::{
CharCreateReq, CharEnumItem, Character, ItemInfo, JumpInfo, MonsterMoveFacing, ObjectFields,
ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK,
CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
CharCreateReq, CharEnumItem, Character, CreateSpline, ItemInfo, JumpInfo, MonsterMoveFacing,
ObjectFields, ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST,
CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME,
};
pub use world::{WardenRequired, WorldReader, WorldSession, WorldWriter, WORLD_PORT};
+2 -2
View File
@@ -159,8 +159,8 @@ pub use trade::{
};
pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell};
pub use update_object::{
quest_slot_state, MovementBlock, Object, ObjectFields, ObjectType, PlayerSkillSlot,
QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK,
quest_slot_state, CreateSpline, MovementBlock, Object, ObjectFields, ObjectType,
PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK,
PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS,
UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
@@ -102,6 +102,13 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result<ServerPacket> {
/// in travel order. We invert each (`waypoint = endpoint − offset`) and append the endpoint, so the
/// returned list is `[waypoint₀ ≈ start, waypoint₁, …, endpoint]`. `waypoint₀` re-encodes the packet's
/// `start` (¼-yd quantized); the caller drops it in favour of the exact `start`.
/// **`count == 2` carries no offsets at all**: vmangos guards the offset loop with `last_idx > 1`
/// (`packet_builder.cpp:92`) while still writing `last_idx + 1` as the count, so a plain two-point
/// `MoveTo` announces 2 points and ships only the destination. Reading the phantom offset anyway
/// over-runs the body and the whole packet is skipped — i.e. that creature stops dead until its next
/// move, the same freeze decision 0708 is about. Never observed on this deploy (its pathfinder emits
/// 3+ point paths, verified over four live runs: no `nodes=2` in any `csp`/`mmv` trace), but a map
/// without mmaps would hit it on every step.
/// - **Flying (Catmull-Rom, `WriteCatmullRomPath`):** a `u32` count, then `count` **absolute** `Vector3d`s
/// (the control points from `getPoint(2)` on — the post-start waypoints through the endpoint). Returned
/// verbatim. (Reading these as packed offsets — the old single-layout path — mis-sized the body and
@@ -129,7 +136,10 @@ fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result<Vec<
}
let endpoint = Vector3d::read(r)?;
let mut points = Vec::with_capacity(cap);
for _ in 1..count {
// `count == 2` ⇒ the producer skipped its offset loop entirely (`last_idx > 1`); the destination is
// the whole payload, and the caller pairs it with the packet's exact `start`.
let offsets = if count > 2 { count - 1 } else { 0 };
for _ in 0..offsets {
let off = packed_to_vector3d(read_i32_le(r)?);
points.push(Vector3d {
x: endpoint.x - off.x,
@@ -162,12 +172,17 @@ mod tests {
/// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath`:
/// count = number of points, the **endpoint** written absolute, then each earlier point as a packed
/// `endpoint − point` offset in travel order. `path` is the full `[start, …mids…, endpoint]`.
/// The `last_idx > 1` guard is transcribed too: a **two**-point path writes the count and the
/// destination and stops — no offsets at all, not even for the start.
fn append_linear_path(body: &mut Vec<u8>, path: &[[f32; 3]]) {
let (&endpoint, leading) = path.split_last().expect("a path has an endpoint");
body.extend_from_slice(&(path.len() as u32).to_le_bytes());
for f in endpoint {
body.extend_from_slice(&f.to_le_bytes());
}
if path.len() <= 2 {
return; // vmangos `packet_builder.cpp:92` — `if (last_idx > 1)`
}
// `leading` = [start, …mids…]; each packed as endpoint − point (¼-yd quantized).
for &p in leading {
let pack = |v: f32, shift: u32, mask: i32| ((v * 4.0).round() as i32 & mask) << shift;
@@ -227,6 +242,26 @@ mod tests {
}
}
/// The plain two-point hop — a `MoveTo` with no intermediate waypoints. vmangos announces `count = 2`
/// but ships **only** the destination (its offset loop is guarded by `last_idx > 1`), so a decoder
/// that trusts the count and reads `count − 1` offsets over-runs the body and the packet is skipped
/// entirely — the creature freezes where it stands. Encoded here by the transcribed producer above.
#[test]
fn monster_move_two_point_path_carries_no_offsets() {
let mut body = head(0x77, [4.0, 8.0, 0.0], 0);
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground
body.extend_from_slice(&1_000u32.to_le_bytes()); // duration
append_linear_path(&mut body, &[[4.0, 8.0, 0.0], [12.0, 8.0, 0.0]]);
let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a two-point hop parses");
match p {
ServerPacket::MonsterMove { path, .. } => {
assert_eq!(path.len(), 2, "start + destination, got {path:?}");
assert!((path[0].x - 4.0).abs() < 1e-6 && (path[1].x - 12.0).abs() < 1e-6);
}
_ => panic!("expected MonsterMove"),
}
}
#[test]
fn monster_move_ground_path_decodes_every_waypoint() {
// A four-waypoint ground patrol, encoded exactly as vmangos `WriteLinearPath` ships it, must
@@ -17,7 +17,7 @@ pub use fields::{
AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, PLAYER_EXPLORED_ZONES_SLOTS,
PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
pub use movement::{MovementBlock, ObjectType};
pub use movement::{CreateSpline, MovementBlock, ObjectType};
/// One entry in an `SMSG_UPDATE_OBJECT` object list.
pub enum Object {
@@ -45,11 +45,53 @@ const UPDATE_FLAG_HAS_POSITION: u8 = 0x40;
const SPLINE_FLAG_FINAL_POINT: u32 = 0x1_0000;
const SPLINE_FLAG_FINAL_TARGET: u32 = 0x2_0000;
const SPLINE_FLAG_FINAL_ANGLE: u32 = 0x4_0000;
/// `MoveSplineFlag::Flying` — and in vmangos 1.12 that bit alone *is* `Mask_CatmullRom`
/// (`MoveSplineFlag.h:48,77`), the same split [`crate::ServerPacket::MonsterMove`] reads: set ⇒ a 3-D
/// flight path that keeps its own Z, clear ⇒ a ground walk whose Z the client re-derives from terrain.
const SPLINE_FLAG_FLYING: u32 = 0x200;
/// `MoveSplineFlag::Cyclic` (`MoveSplineFlag.h:59`) — the path loops back on itself forever.
const SPLINE_FLAG_CYCLIC: u32 = 0x10_0000;
/// The **live spline a unit is already riding** when it streams into view — the `LIVING` block's
/// `MOVEFLAG_SPLINE_ENABLED` tail (vmangos `Object.cpp:535` → `PacketBuilder::WriteCreate`,
/// `packet_builder.cpp:152`). Without it, a creature that was walking when we first saw it stands
/// frozen at its create pose until the server's *next* `SMSG_MONSTER_MOVE` — which then teleports it
/// to wherever it actually got to (decision 0708).
///
/// [`Self::path`] is the **travel-order polyline**, the same shape `MonsterMove` decodes to. The wire
/// carries the server's raw internal control array (`MoveSpline::getPath()` = `Spline::getPoints()`),
/// which is *not* that: vmangos builds every spline — linear ones included — through
/// `SplineBase::InitCatmullRom` ("we should use catmullrom initializer even for linear mode!",
/// `spline.cpp:52`), so the array is `[phantom, p₀, …, pₙ, tail]` whose walkable range is
/// `index_lo = 1 ..= index_hi = len − 2` (`spline.cpp:238-270`): the phantom head is `p₀` extrapolated
/// one segment *backwards*, and the tail duplicates the destination (or, cyclic, closes the loop).
/// We hand over exactly that range.
#[derive(Debug, Clone)]
pub struct CreateSpline {
/// The full travel-order polyline in raw WoW coords: `[start, …waypoints…, endpoint]`.
pub path: Vec<[f32; 3]>,
/// The server's spline id (`MoveSpline::GetId`), as on `MonsterMove`.
pub id: u32,
/// Milliseconds of this spline the server has **already ridden** (`MoveSpline::timePassed`): the
/// unit is `time_passed_ms / duration_ms` of the way along `path`, so a client that starts the
/// ride at `now − time_passed_ms` lands exactly where the server has it.
pub time_passed_ms: u32,
/// The whole path's duration in ms (`MoveSpline::Duration` = the spline's length, which vmangos
/// initialises in *time* units at one constant velocity — `CommonInitializer`,
/// `MoveSpline.cpp:125-135`). Time is therefore uniform in arc length along the whole polyline,
/// which is what makes the `time_passed` fraction above exact.
pub duration_ms: u32,
/// `MoveSplineFlag::Flying`: a 3-D flight path (keep the spline's Z); clear for a ground walk.
pub flying: bool,
/// `MoveSplineFlag::Cyclic` — the server loops this path forever. The app rides it once; a looping
/// follow is unbuilt (the same gap `MonsterMove`'s cyclic paths have).
pub cyclic: bool,
}
/// The decoded fields a movement block carries: the pose (from its `LIVING`/`HAS_POSITION` flag),
/// for a `LIVING` block the unit's 6 movement speeds and (if `ON_TRANSPORT`) its rider pose, and (if
/// `UPDATE_FLAG_TRANSPORT`) a transport GameObject's path progress. The rest of the block is parsed and
/// discarded to stay aligned.
/// for a `LIVING` block the unit's 6 movement speeds, its live [`CreateSpline`] and (if
/// `ON_TRANSPORT`) its rider pose, and (if `UPDATE_FLAG_TRANSPORT`) a transport GameObject's path
/// progress. The rest of the block is parsed and discarded to stay aligned.
pub struct MovementBlock {
pub position: Option<(Vector3d, f32)>,
/// A `LIVING` block's 6 movement speeds (yd/s) in wire order `[walk, run, run_back, swim, swim_back,
@@ -72,6 +114,9 @@ pub struct MovementBlock {
/// (decision 0438 "Riding is the mover's platform frame" — an observed rider carried this way
/// re-anchors through the transport's live matrix each frame, never treating `pos` as world-space).
pub transport: Option<TransportPose>,
/// The path this unit is **already walking** at create time (`MOVEFLAG_SPLINE_ENABLED`) — see
/// [`CreateSpline`]. `None` for a unit standing still, and for every non-`LIVING` block.
pub spline: Option<CreateSpline>,
}
impl MovementBlock {
@@ -81,6 +126,7 @@ impl MovementBlock {
let mut speeds = None;
let mut transport = None;
let mut transport_progress = None;
let mut spline = None;
if update_flag & UPDATE_FLAG_LIVING != 0 {
let flags = read_u32_le(r)?;
@@ -121,6 +167,10 @@ impl MovementBlock {
speeds = Some(s);
if flags & MOVEMENT_FLAG_SPLINE_ENABLED != 0 {
let spline_flags = read_u32_le(r)?;
// The dictated final facing, in vmangos's own angle → target → point order
// (`packet_builder.cpp:162-167`). Parsed for alignment only: a unit still riding a
// path faces its travel tangent every frame, exactly as `MonsterMove`'s facing snap is
// overwritten while a path runs (`net::apply::objects::monster_move`).
if spline_flags & SPLINE_FLAG_FINAL_ANGLE != 0 {
let _ = read_f32_le(r)?;
} else if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 {
@@ -128,14 +178,28 @@ impl MovementBlock {
} else if spline_flags & SPLINE_FLAG_FINAL_POINT != 0 {
let _ = Vector3d::read(r)?;
}
let _time_passed = read_u32_le(r)?;
let _duration = read_u32_le(r)?;
let _id = read_u32_le(r)?;
let time_passed_ms = read_u32_le(r)?;
let duration_ms = read_u32_le(r)?;
let id = read_u32_le(r)?;
let amount_of_nodes = read_u32_le(r)?;
let mut nodes = Vec::with_capacity(amount_of_nodes.min(0xFFFF) as usize);
for _ in 0..amount_of_nodes {
let _ = Vector3d::read(r)?;
let v = Vector3d::read(r)?;
nodes.push([v.x, v.y, v.z]);
}
// The final destination trails the array; it is `path`'s last real point again
// (`MoveSpline::FinalDestination` = `getPoint(last())`), or zero on a cyclic path.
let _final_node = Vector3d::read(r)?;
// Drop the server's two virtual control points — see [`CreateSpline`]. Fewer than
// four nodes cannot hold a two-point path, so there is nothing to ride.
spline = (nodes.len() >= 4).then(|| CreateSpline {
path: nodes[1..nodes.len() - 1].to_vec(),
id,
time_passed_ms,
duration_ms,
flying: spline_flags & SPLINE_FLAG_FLYING != 0,
cyclic: spline_flags & SPLINE_FLAG_CYCLIC != 0,
});
}
} else if update_flag & UPDATE_FLAG_HAS_POSITION != 0 {
let pos = Vector3d::read(r)?;
@@ -161,6 +225,7 @@ impl MovementBlock {
speeds,
transport_progress,
transport,
spline,
})
}
}
@@ -447,6 +447,140 @@ fn unit_living_block_surfaces_on_transport_rider_pose() {
}
}
/// A unit that is **already walking** when it streams in: the `LIVING` block's
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) tail, byte-shaped exactly as vmangos writes it
/// (`PacketBuilder::WriteCreate`, `packet_builder.cpp:152`). Two things are verified, and both are
/// what decision 0708 turns on:
///
/// 1. The node array is the server's **internal** control array — `[phantom, p₀…pₙ, tail]`, since
/// vmangos builds even a linear spline through `InitCatmullRom` (`spline.cpp:52`) — so the decoded
/// `path` must be that array with its two virtual points trimmed, not the raw nodes.
/// 2. The descriptor mask *after* the spline still parses: a mis-sized spline read (a missed node, a
/// forgotten final destination) silently shifts every field that follows, which is precisely how a
/// parse-and-discard block hides its own bugs.
#[test]
fn unit_living_block_surfaces_the_walk_it_is_already_on() {
// The real path: an L, 10 yd east then 20 yd north. What the server actually serializes is that
// path wrapped in its two virtual points: a phantom head at `p₀.lerp(p₁, -1)` = 2p₀ − p₁ (one
// segment *behind* the start) and a tail duplicating the destination.
let path = [
[10.0f32, 20.0, 30.0],
[20.0, 20.0, 30.0],
[20.0, 40.0, 30.0],
];
let nodes = [
[0.0f32, 20.0, 30.0], // phantom: 2·p₀ − p₁
path[0],
path[1],
path[2],
path[2], // tail: the destination again
];
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // update_type: CREATE_OBJECT
write_packed_guid(0xAA, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // movement block: LIVING only
let flags: u32 = 0x0040_0000; // MOVEFLAG_SPLINE_ENABLED (vmangos MovementInfo.h:53)
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
// The unit's stored pose — vmangos re-syncs it to the spline only every 400 ms, so it trails the
// true position; the spline below is what actually places the unit.
for v in [14.0f32, 20.0, 30.0, 0.0] {
body.extend_from_slice(&v.to_le_bytes()); // living pos + orientation
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds
}
// The spline tail: flags, time_passed, duration, id, node count, the nodes, final destination.
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground walk, no dictated facing
body.extend_from_slice(&3_000u32.to_le_bytes()); // time_passed: a quarter of the ride is done
body.extend_from_slice(&12_000u32.to_le_bytes()); // duration
body.extend_from_slice(&42u32.to_le_bytes()); // spline id
body.extend_from_slice(&(nodes.len() as u32).to_le_bytes());
for n in nodes {
for f in n {
body.extend_from_slice(&f.to_le_bytes());
}
}
for f in path[2] {
body.extend_from_slice(&f.to_le_bytes()); // FinalDestination
}
// A descriptor field behind the spline: UNIT_FIELD_DISPLAYID (131) = block 4, bit 3.
body.push(5); // 5 mask blocks
for m in [0u32, 0, 0, 0, 1 << 3] {
body.extend_from_slice(&m.to_le_bytes());
}
body.extend_from_slice(&1234u32.to_le_bytes());
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let events = decode(packet);
match events.as_slice() {
[SessionEvent::ObjectCreate {
guid: 0xAA,
kind: EntityKind::Unit,
display_id,
position,
spline,
..
}] => {
let s = spline.as_ref().expect("the create block's live spline");
assert_eq!(
s.path, path,
"the two virtual control points are the server's, not waypoints"
);
assert_eq!(s.time_passed_ms, 3_000);
assert_eq!(s.duration_ms, 12_000);
assert_eq!(s.id, 42);
assert!(!s.flying, "no Flying bit ⇒ a ground walk");
assert!(!s.cyclic);
assert_eq!(
*position,
[14.0, 20.0, 30.0],
"the block's own pose is the server's last 400 ms sync, kept as the spawn pose"
);
assert_eq!(
*display_id,
Some(1234),
"the descriptor mask behind the spline still parses — the block was sized right"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// A unit standing still carries no spline tail at all — the flag gates it, so `spline` is `None` and
/// nothing downstream tries to walk it.
#[test]
fn unit_living_block_without_the_spline_flag_carries_no_walk() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // CREATE_OBJECT
write_packed_guid(0xAB, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // LIVING
body.extend_from_slice(&0u32.to_le_bytes()); // no move flags
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
for v in [1.0f32, 2.0, 3.0, 0.5] {
body.extend_from_slice(&v.to_le_bytes());
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
body.extend_from_slice(&v.to_le_bytes());
}
body.push(0); // no descriptor fields
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate { spline, .. }] => assert!(spline.is_none()),
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// `PLAYER_VISIBLE_ITEM_<n>_0` across the slot range — the PUBLIC entry the inspect window's whole
/// paper doll is built from (decision 0631) and the same field equipment rendering resolves other
/// players' gear through. Untested until now, and an off-by-one in its `258 + 2 + 12i` stride would
@@ -58,6 +58,12 @@ const RAIN_FOG_GREY: vec3<f32> = vec3<f32>(0.50196078, 0.50196078, 0.50196078);
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
// `$WOW_PARTICLE_FLAT` (B16 instrument): solid magenta, no inputs — see material.rs.
#ifdef WOW_PARTICLE_FLAT
var flat_out: FragmentOutput;
flat_out.color = vec4<f32>(1.0, 0.0, 1.0, 1.0);
return flat_out;
#else
// HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The reference
// has ONE projection far plane and it clips the whole detailed world: terrain, models, liquid AND
// the effect family, which is drawn by the same scene pass through the same matrix. benilla
@@ -123,4 +129,5 @@ fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> Fragment
#endif
out.color = main_pass_post_lighting_processing(pbr_input, final_color);
return out;
#endif
}
+177 -42
View File
@@ -1,5 +1,5 @@
//! Background instrumented runs: probe/capture/regression windows that never fight the
//! director's screen.
//! Background instrumented runs: probe/capture/regression windows that open *behind* the
//! director's work — and stay reachable once they're there.
//!
//! Agent-driven runs (captures, live probes, the rig, FPS journals) open a real window for
//! seconds to minutes on the same desktop the director is working on. Left alone, each launch
@@ -11,18 +11,30 @@
//! live probe still opened key.
//! - **App-side (macOS)** — winit's AppKit backend unconditionally calls
//! `activateIgnoringOtherApps` at `applicationDidFinishLaunching` and sets the Regular
//! activation policy, so even an unfocused run activated the app: menu bar switched, a Dock
//! icon appeared, and the window ordered in **front** of every other app's. bevy_winit 0.18
//! exposes no `EventLoopBuilderExtMacOS` hook to turn either off, so it can't be prevented —
//! only undone, immediately after launch (decision 0703).
//! activation policy, so even an unfocused run activated the app: the menu bar switched and
//! the window ordered in **front** of every other app's. bevy_winit 0.18 exposes no
//! `EventLoopBuilderExtMacOS` hook to turn either off, so it can't be prevented — only undone,
//! immediately after launch (decision 0703).
//!
//! Stealing the screen is a **launch-time event**, and only an event's worth of correction is
//! owed. 0703 paid for it with two *permanent* states, and the bill came due: with
//! `WindowLevel::AlwaysOnBottom` (`kCGNormalWindowLevel - 1`) the window could never be raised
//! above a normal one however hard you clicked it, and the Accessory activation policy struck it
//! from Cmd-Tab and the Dock. Every instrumented window was unreachable for its whole life, not
//! just polite at birth. Decision 0709 keeps both mechanisms — they are the right ones, and
//! Accessory in particular is doing more work than it looks (see [`macos::hand_back_activation`])
//! — but scopes them to the launch instead of the run:
//!
//! [`background_run`] detects an instrumented run from the env that drives it (no recipe or
//! script changes anywhere), `main` opens the window unfocused **and**
//! [`bevy::window::WindowLevel::AlwaysOnBottom`] — on screen (so surface rendering, readback
//! and screenshots are untouched; it sits at `kCGNormalWindowLevel - 1`) but *under* every
//! normal window — and [`BgWinPlugin`] demotes the macOS app to the Accessory activation
//! policy (no Dock icon, no Cmd-Tab entry, no menu-bar takeover) and hands activation straight
//! back to whatever the director was using.
//! script changes anywhere). `main` opens the window unfocused and below the normal level, so it
//! cannot flash over the director's work on the way up. [`BgWinPlugin`] then holds the launch:
//! Accessory policy, activation handed straight back to whatever they were using, and our window
//! ordered to the **back of the stack** — on screen, so surface rendering, readback and
//! screenshots are untouched, but behind their work. Once the launch goes quiet (half a second
//! with no activation landing on us; hard-capped at three seconds) it lets go in sequence —
//! window level back to Normal, policy back to Regular, one last order-to-back — and latches
//! shut. What's left is an ordinary window that Cmd-Tabs, clicks and raises like any other, for
//! as long as the run lasts.
use bevy::prelude::*;
@@ -71,9 +83,9 @@ pub fn background_run() -> bool {
})
}
/// On a [`background_run`], undo winit's launch-time app activation (macOS; no-op elsewhere).
/// The window-side half (unfocused + always-on-bottom) lives where the window is built, in
/// `main` — this plugin owns only the app-side half.
/// On a [`background_run`], undo winit's launch-time app activation and window ordering (macOS;
/// no-op elsewhere) for the length of the launch window, then get out of the way. The
/// `focused: false` half lives where the window is built, in `main`.
pub struct BgWinPlugin;
impl Plugin for BgWinPlugin {
@@ -82,11 +94,11 @@ impl Plugin for BgWinPlugin {
return;
}
info!(
"bgwin: instrumented run — window opens unfocused at the bottom of the stack, \
app demoted from Dock/Cmd-Tab (WOW_BG=0 for a normal focused window)"
"bgwin: instrumented run — window opens unfocused behind your work, then behaves \
normally (Cmd-Tab/click to raise it; WOW_BG=0 for a normal focused window)"
);
#[cfg(target_os = "macos")]
app.add_systems(PreStartup, macos::demote)
app.add_systems(PreStartup, macos::hand_back_activation)
.add_systems(Update, macos::hold_background);
#[cfg(not(target_os = "macos"))]
let _ = app;
@@ -97,48 +109,171 @@ impl Plugin for BgWinPlugin {
mod macos {
use bevy::ecs::system::NonSendMarker;
use bevy::prelude::*;
use bevy::time::Real;
use bevy::window::{PrimaryWindow, WindowLevel};
use core::time::Duration;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
use objc2_foundation::MainThreadMarker;
/// How many frames [`hold_background`] keeps watching for a late-landing activation.
/// winit's `activateIgnoringOtherApps` is a WindowServer round-trip: it can be *granted*
/// after `PreStartup`'s [`demote`] already ran, which would leave the app active with no
/// one to deactivate it. A couple of seconds of frames covers the grant comfortably.
const HOLD_FRAMES: u32 = 120;
/// How quiet the launch has to go before [`hold_background`] calls it settled and lets go:
/// this long with the app continuously inactive. winit's `activateIgnoringOtherApps` is a
/// WindowServer round-trip that can be *granted* well after `PreStartup`'s
/// [`hand_back_activation`] already ran — and a granted activation re-raises our window — so
/// the hold can't be a fixed count; every activation it sees restarts the clock.
const SETTLE: Duration = Duration::from_millis(500);
/// `PreStartup` (after winit's `applicationDidFinishLaunching` set Regular + activated):
/// demote to Accessory — no Dock icon, no Cmd-Tab entry, no menu-bar takeover — and give
/// activation back to the app the director was using. The `NonSendMarker` param pins the
/// system to the main thread (AppKit's requirement).
pub(super) fn demote(_main_thread: NonSendMarker) {
/// Hard ceiling on the hold, however unsettled things look. Past this the window is the
/// director's, and an unrecognised activation source is a bug to find, not to fight forever.
const CEILING: Duration = Duration::from_secs(3);
/// How long to keep watching after the promotion to Regular. Raising the activation policy
/// is the one step that could itself pull the app forward, so the release is not trusted
/// blind — it is checked for a moment, then latched shut.
const TAIL: Duration = Duration::from_millis(300);
/// Where the launch correction has got to. The whole plugin is this one-way sequence.
#[derive(Default)]
pub(super) enum Phase {
/// Undoing winit's activation and raise until the launch goes quiet.
#[default]
Holding,
/// Quiet reached: the window level has been retargeted at Normal and bevy applies the
/// `setLevel` this frame — which re-orders us to the front of the level we just joined.
Promoting,
/// Regular policy restored (Dock icon, Cmd-Tab entry). Watching for a moment in case that
/// promotion pulled the app forward with it.
Tail(Duration),
/// Hands off, permanently.
Done,
}
/// Give activation back to the app the director was using. The `NonSendMarker` param pins
/// the system to the main thread (AppKit's requirement).
///
/// Demoting to the Accessory policy for the launch is load-bearing, and not merely to hide
/// the Dock icon: `deactivate()` on its own does **not** hold a Regular-policy app in the
/// background on macOS 26 — measured, the app took frontmost within 160 ms of launch and kept
/// it for the whole run, which is the 2026-07-19 keystroke-stealing bug exactly. Accessory is
/// what actually keeps us out of the foreground. Its cost — no Cmd-Tab entry — is why it is
/// wrong to *leave* on (that is what stranded the window), so it is scoped to the launch and
/// promoted back to Regular the moment things settle (decision 0709).
pub(super) fn hand_back_activation(_main_thread: NonSendMarker) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
// SAFETY: main thread (the `NonSendMarker` param + the marker check above).
unsafe {
if app.isActive() {
app.deactivate();
}
}
let _ = hand_back(&app);
}
/// The first [`HOLD_FRAMES`] frames: if the launch-time activation lands *after*
/// [`demote`], hand it back again. Self-quiets after the window; a no-op thereafter.
pub(super) fn hold_background(_main_thread: NonSendMarker, mut frames: Local<u32>) {
if *frames > HOLD_FRAMES {
/// The launch window: keep handing activation back and keep our window at the back of the
/// stack until the launch settles, then let go for good. A no-op thereafter — from that point
/// the window is ordinary, and raising it is between the director and the WindowServer.
///
/// Deliberately *not* keyed on window focus, which reads like the obvious "the director
/// reached for it" signal and isn't: winit's `window_activation_hack`
/// (`app_state.rs`, `applicationDidFinishLaunching`) calls `makeKeyAndOrderFront` on every
/// visible window regardless of the `focused: false` we asked for, so the launch itself
/// delivers a focus gain on ~frame 2 — and an activation granted later delivers another, as
/// the key window resigns and re-takes key across the app deactivate/activate. Focus cannot
/// tell us apart from the WindowServer, so the hold is timed against quiet instead, and kept
/// short enough that it can't meaningfully fight a director who clicks during it.
pub(super) fn hold_background(
_main_thread: NonSendMarker,
time: Res<Time<Real>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut span: Local<Option<(Duration, Duration)>>,
mut phase: Local<Phase>,
) {
if matches!(*phase, Phase::Done) {
return;
}
*frames += 1;
let now = time.elapsed();
let (started, last_active) = span.get_or_insert((now, now));
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
// SAFETY: main thread (the `NonSendMarker` param + the marker check above).
match *phase {
Phase::Holding => {
if now.saturating_sub(*last_active) < SETTLE
&& now.saturating_sub(*started) < CEILING
{
if hand_back(&app) {
// An activation landed (winit's, granted late). Restart the settle clock:
// whatever raised us once can raise us again while the round-trip is
// still in flight.
*last_active = now;
debug!("bgwin: took activation back {now:?} in");
}
order_back(&app);
return;
}
// Quiet. Start letting go — the window level first, retargeted through the
// component so bevy's own cache stays honest about where the window is.
let Ok(mut window) = windows.single_mut() else {
return;
};
window.window_level = WindowLevel::Normal;
*phase = Phase::Promoting;
}
Phase::Promoting => {
// bevy applied the `setLevel` at the end of last frame, which put us at the front
// of the normal level; drop straight back to the bottom of it before anything is
// presented on top of the director's work. Then hand the Dock icon and the
// Cmd-Tab entry back.
order_back(&app);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
*phase = Phase::Tail(now);
}
Phase::Tail(since) => {
if hand_back(&app) {
// The promotion pulled us forward after all — put it back.
debug!("bgwin: policy promotion activated the app; handed it back");
}
order_back(&app);
if now.saturating_sub(since) >= TAIL {
*phase = Phase::Done;
info!(
"bgwin: launch settled — ordinary window now (Cmd-Tab or click to raise it)"
);
}
}
Phase::Done => {}
}
}
/// Give activation back to whoever had it before we launched. Reports whether there was any
/// to give back — i.e. whether an activation had landed on us since the last look.
fn hand_back(app: &NSApplication) -> bool {
// SAFETY: main thread — every caller takes `NonSendMarker` and checks `MainThreadMarker`.
unsafe {
if app.isActive() {
app.deactivate();
if !app.isActive() {
return false;
}
app.deactivate();
true
}
}
/// Order our windows to the back of the normal window level: on screen (surface rendering,
/// readback and screenshots untouched) but behind the director's work.
///
/// Plain stacking, not a window level — that is the whole point. `AlwaysOnBottom` pins a
/// window below every normal one *forever*; a window ordered to the back is merely last in
/// line, and comes forward the instant anything raises it.
///
/// Unconditional for the length of the hold, and deliberately not gated on "the app is
/// active": winit raises the window when it creates it and again in its
/// `window_activation_hack`, both of which can land while we are already inactive, so a gate
/// on activation would leave that raise standing and the window parked on top for the whole
/// run. Repeating it while already at the back is an idempotent no-op.
fn order_back(app: &NSApplication) {
// SAFETY: main thread — every caller takes `NonSendMarker` and checks `MainThreadMarker`.
unsafe {
for window in app.windows().iter() {
window.orderBack(None);
}
}
}
+21 -5
View File
@@ -104,13 +104,29 @@ impl Plugin for DepthProbePlugin {
// transmissive/transparent ones. That is the depth as it stood when the opaque fight was
// decided, which is the question — see [`DepthReadbackNode`] for what reading it later
// gets you instead.
//
// `WOW_DEPTH_AFTER=1` moves the copy to AFTER the transparent pass — deliberately
// measuring the thing the placement note warns about: the depth the transparent pass
// itself wrote. B38 first observed that phantom ("5–8 yd at pixels where the ray cast
// finds the tower at 32–43 yd … from something that tracks the camera") and never named
// it; B16's eye quads lose a depth compare that the opaque-pass buffer says they win,
// so the same writer is now the suspect. Before-vs-after at the same pixels names its
// depth, and its depth names it.
.add_render_graph_edges(
Core3d,
(
Node3d::MainOpaquePass,
DepthReadbackLabel,
Node3d::MainTransmissivePass,
),
if std::env::var_os("WOW_DEPTH_AFTER").is_some() {
(
Node3d::MainTransparentPass,
DepthReadbackLabel,
Node3d::EndMainPass,
)
} else {
(
Node3d::MainOpaquePass,
DepthReadbackLabel,
Node3d::MainTransmissivePass,
)
},
);
}
}
+101 -14
View File
@@ -25,8 +25,11 @@
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::mesh::RenderMesh;
use bevy::render::render_asset::RenderAssets;
use bevy::render::render_phase::{ViewBinnedRenderPhases, ViewSortedRenderPhases};
use bevy::render::sync_world::MainEntity;
use bevy::render::texture::GpuImage;
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::interact::WorldObject;
@@ -36,13 +39,20 @@ pub(crate) struct PhaseProbePlugin;
impl Plugin for PhaseProbePlugin {
fn build(&self, app: &mut App) {
let Some(object) = std::env::var("WOW_PHASE")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
else {
warn!("phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) — inert");
let raw = std::env::var("WOW_PHASE").ok();
// `WOW_PHASE=particles` — the same question asked of every live PARTICLE emitter's quad
// mesh instead of one placement's batches (B16: a pool that is emitted, meshed, visible
// and textured, whose pixels never change — "was its mesh ever SUBMITTED to a phase?" is
// exactly the gap between the sim-side depth dump and the framebuffer).
let particles = raw.as_deref().is_some_and(|v| v.trim() == "particles");
let object = raw.as_deref().and_then(|v| v.trim().parse::<u32>().ok());
if !particles && object.is_none() {
warn!(
"phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) or `particles` — inert"
);
return;
};
}
let object = object.unwrap_or(0);
let at = std::env::var("WOW_PHASE_AT")
.ok()
.and_then(|v| v.parse().ok())
@@ -54,12 +64,13 @@ impl Plugin for PhaseProbePlugin {
.max(1);
app.insert_resource(PhaseWatch {
object,
particles,
at,
count,
batches: Vec::new(),
armed: false,
})
.add_systems(Update, collect_batches)
.add_systems(Update, (collect_batches, collect_emitters))
.add_plugins(ExtractResourcePlugin::<PhaseWatch>::default());
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
warn!("phase: no render app — inert");
@@ -77,10 +88,14 @@ impl Plugin for PhaseProbePlugin {
struct PhaseWatch {
/// The placement `uniqueId` whose batches to follow.
object: u32,
/// `WOW_PHASE=particles`: follow every live particle emitter's quad mesh instead.
particles: bool,
at: f32,
count: u32,
/// The watched batches: main-world entity, its WMO batch order, and how it draws.
batches: Vec<(Entity, i32, String)>,
/// The watched batches: main-world entity, its WMO batch order, how it draws, and (particles
/// mode) its mesh + texture assets — so the render half can say what the GPU-side copies hold
/// at draw time.
batches: Vec<WatchedBatch>,
/// Have we found the object and started sampling?
armed: bool,
}
@@ -92,6 +107,10 @@ impl ExtractResource for PhaseWatch {
}
}
/// One watched batch: entity, WMO batch order (or emitter bone), how it draws, and — particles
/// mode only — the mesh + texture asset ids the render half resolves to GPU-side state.
type WatchedBatch = (Entity, i32, String, Option<(AssetId<Mesh>, AssetId<Image>)>);
/// Find the object's batch entities once it has streamed in. Re-run until it is found, then hold:
/// the set is per-placement and does not change, and re-collecting every frame would make the
/// watch list itself a moving part of the measurement.
@@ -113,7 +132,7 @@ fn collect_batches(
return;
}
let want = watch.object;
let mut found: Vec<(Entity, i32, String)> = parts
let mut found: Vec<WatchedBatch> = parts
.iter()
.filter(|(_, obj, _, _)| obj.id == want)
.map(|(e, _, part, mat)| {
@@ -123,14 +142,14 @@ fn collect_batches(
let order = materials
.get(&mat.0)
.map_or(-1, |m| m.extension.sun_scale.y as i32);
(e, order, format!("{:?}", part.blend))
(e, order, format!("{:?}", part.blend), None)
})
.collect();
if found.is_empty() {
return;
}
// Sort by batch order so the report reads in the WMO's authored order, not spawn order.
found.sort_by_key(|&(_, order, _)| order);
found.sort_by_key(|&(_, order, _, _)| order);
info!(
"phase: watching {} batches of #{want} for {} frames",
found.len(),
@@ -140,12 +159,50 @@ fn collect_batches(
watch.armed = true;
}
/// The `particles` mode's collector: every LIVE particle emitter's own quad-mesh entity, labeled
/// by its emitter bone + blend (the same identity the sim-side depth dump prints, so a phase line
/// and a dump line name the same pool). One-shot at the window like [`collect_batches`].
fn collect_emitters(
mut watch: ResMut<PhaseWatch>,
time: Res<Time>,
emitters: Query<(Entity, &crate::particles::ParticleEmitter, &Mesh3d)>,
) {
if !watch.particles || watch.armed || time.elapsed_secs() < watch.at {
return;
}
let found: Vec<WatchedBatch> = emitters
.iter()
.filter(|(_, e, _)| e.live() > 0)
.map(|(ent, e, mesh)| {
let def = e.def();
(
ent,
i32::from(def.bone),
format!("{:?}(pool {})", def.blend, e.live()),
Some((mesh.0.id(), e.texture().id())),
)
})
.collect();
if found.is_empty() {
return;
}
info!(
"phase: watching {} live emitter meshes for {} frames",
found.len(),
watch.count
);
watch.batches = found;
watch.armed = true;
}
/// Where each watched batch sits in each phase, this frame.
fn report_phases(
watch: Option<Res<PhaseWatch>>,
opaque: Res<ViewBinnedRenderPhases<Opaque3d>>,
alpha_mask: Res<ViewBinnedRenderPhases<AlphaMask3d>>,
transparent: Res<ViewSortedRenderPhases<Transparent3d>>,
render_meshes: Res<RenderAssets<RenderMesh>>,
gpu_images: Res<RenderAssets<GpuImage>>,
mut seen: Local<u32>,
) {
let Some(watch) = watch else { return };
@@ -168,7 +225,36 @@ fn report_phases(
binned_total(&alpha_mask),
transparent_total,
);
for &(entity, submesh, ref blend) in &watch.batches {
// `particles` mode: the transparent phase's TAIL — the items drawn LAST, i.e. over everything
// else. A watched mesh that is submitted, late, correct and still invisible means someone
// even later paints its pixels; this names the suspects with their sort distances.
if watch.particles {
if let Some(phase) = transparent.values().max_by_key(|p| p.items.len()) {
let n = phase.items.len();
for (i, item) in phase.items.iter().enumerate().skip(n.saturating_sub(25)) {
info!(
"phase#{frame} tail @{i} {:?} dist {:.3}",
item.entity.1, item.distance
);
}
}
}
for &(entity, submesh, ref blend, assets) in &watch.batches {
// What the GPU-side copies hold at draw time — the last links between "submitted to a
// phase" and "fragments exist": a phase item whose RenderMesh is absent draws nothing,
// and one whose material TEXTURE never became a GpuImage has no material bind group, so
// the draw command chain aborts silently every frame.
let gpu = assets.map(|(mesh_id, tex_id)| {
let mesh = match render_meshes.get(mesh_id) {
None => "gpu_mesh MISSING".to_string(),
Some(m) => format!("gpu_verts {}", m.vertex_count),
};
let tex = match gpu_images.get(tex_id) {
None => "gpu_tex MISSING".to_string(),
Some(img) => format!("gpu_tex {:?}", img.texture_format),
};
format!("{mesh} {tex}")
});
let main = MainEntity::from(entity);
let mut found = Vec::new();
// The main view is whichever phase map holds the most entities: shadow and prepass views bin
@@ -192,7 +278,8 @@ fn report_phases(
// (79 entities over 19 orders on the Far Watch Post tower). Keying a report by order alone
// silently merges them and can hide the one batch that moved.
info!(
"phase#{frame} {entity} batch order {submesh:3} {blend:10} -> {}",
"phase#{frame} {entity} batch order {submesh:3} {blend:10} {} -> {}",
gpu.as_deref().unwrap_or(""),
if found.is_empty() {
"NOT SUBMITTED".to_string()
} else {
+12 -6
View File
@@ -288,12 +288,18 @@ fn main() {
// Focused, it steals the keyboard — on 2026-07-19 a login-shot run swallowed
// their keystrokes out of another app and typed them into the account box,
// which is also how that capture lost the bare caret it was taken to measure.
// Merely unfocused, a normal-level window still opens on TOP of their work.
// So every probe/capture/regression run (`bgwin`) opens unfocused at the
// bottom of the window stack — still on screen, so rendering, readback and
// screenshots are untouched. An ordinary `cargo run` is unaffected and
// focuses normally. The app-level half (winit's forced macOS app activation)
// is `BgWinPlugin`'s job below.
// So every probe/capture/regression run (`bgwin`) opens unfocused; an ordinary
// `cargo run` is unaffected and focuses normally.
//
// A background run is BORN at `AlwaysOnBottom` (`kCGNormalWindowLevel - 1`)
// so it can never flash over their work on the way up — winit raises a new
// window twice before our first frame runs, and at the normal level that
// showed as ~half a second of probe window on top (measured). But it does not
// STAY there: that level is a cage, and 0703 leaving it on for the whole run
// is why an instrumented window could never be raised again however hard you
// clicked it. `BgWinPlugin` promotes it back to Normal the moment the launch
// settles (decision 0709), and owns the app-level half — winit's forced macOS
// app activation — as well.
focused: !background,
window_level: if background {
bevy::window::WindowLevel::AlwaysOnBottom
+2
View File
@@ -370,6 +370,7 @@ pub(super) fn apply_net_updates(
speeds,
transport_progress,
transport,
spline,
fields,
} => {
// OUR corpse streaming into range (a TYPEID_CORPSE create whose owner is us):
@@ -390,6 +391,7 @@ pub(super) fn apply_net_updates(
speeds,
transport_progress,
transport,
spline,
fields,
&mut commands,
&mut index,
+36 -8
View File
@@ -14,7 +14,10 @@ use crate::items::Items;
use crate::model_fade::DespawnFade;
use crate::names::NameCache;
use super::super::motion::{monster_move_spline, pose_transform, resolve_facing, write_pose};
use super::super::motion::{
create_spline, monster_move_spline, pose_transform, resolve_facing, trace_create_spline,
trace_move_snap, write_pose,
};
use super::super::{
Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid,
SpeedChangeMessage, Spline, UnitSpeeds,
@@ -33,6 +36,7 @@ pub(super) fn object_create(
speeds: Option<MoveSpeeds>,
transport_progress: Option<u32>,
transport: Option<benilla_protocol::TransportPose>,
spline: Option<benilla_protocol::CreateSpline>,
fields: ObjectFields,
commands: &mut Commands,
index: &mut GuidIndex,
@@ -115,14 +119,15 @@ pub(super) fn object_create(
position[2],
);
}
// The walk this unit is ALREADY on (decision 0708): its create block's live spline, joined at the
// server's own progress along it. Traced before it is interpreted, so the `WOW_CREATE_SPLINE=off`
// leg of the A/B still records what the wire offered.
trace_create_spline(guid, spline.as_ref());
let walk = spline.and_then(create_spline);
if let Some(&e) = index.0.get(&guid) {
// Re-create of a tracked guid: refresh identity + pose, drop any stale path. A create
// is a fresh server snapshot, so any in-flight extrapolation is stale too — clear it.
commands
.entity(e)
.insert(net)
.remove::<Spline>()
.remove::<RemoteMotion>();
// Re-create of a tracked guid: refresh identity + pose. A create is a fresh server snapshot, so
// any in-flight extrapolation is stale too — clear it.
commands.entity(e).insert(net).remove::<RemoteMotion>();
if let Some(s) = speeds {
commands.entity(e).insert(UnitSpeeds(s));
}
@@ -142,6 +147,16 @@ pub(super) fn object_create(
.remove::<crate::transport::TransportRider>();
}
}
// The snapshot's own path outranks whatever we were riding; a create *without* one says the
// unit is standing still, so a stale path goes.
match walk {
Some(s) => {
commands.entity(e).insert(s);
}
None => {
commands.entity(e).remove::<Spline>();
}
}
write_pose(transforms, e, position, orientation);
// Overlay the fresh snapshot's descriptor fields onto the existing store.
merge_fields(stores, pending, e, guid, fields);
@@ -172,6 +187,9 @@ pub(super) fn object_create(
if let Some(r) = rider {
entity.insert(r);
}
if let Some(s) = walk {
entity.insert(s);
}
index.0.insert(guid, entity.id());
// Seed the store via the pending flush — the entity isn't spawned until the sync point.
pending.insert(guid, fields);
@@ -373,6 +391,16 @@ pub(super) fn monster_move(
transforms: &mut Query<&mut Transform>,
) {
if let Some(&e) = index.0.get(&guid) {
// The DESYNC readout (decision 0708): how far this packet is about to teleport the unit — the
// gap between where we have been drawing it and where the server says the path begins. A
// correctly-followed creature reads ~0; a frozen one reads the whole walk it slept through.
trace_move_snap(
guid,
transforms.get(e).ok().map(|t| bevy_to_wow(t.translation)),
start,
stop,
duration_ms,
);
// Apply the dictated final facing (moveType 2/3/4) as a **snap** — faithful to the
// client, which stores it straight into the unit's movement facing (`0x7c6f30`).
// This is the *packet*-driven re-face — a scripted/emote/aggro `SetFacingTo` the
+6 -3
View File
@@ -2,8 +2,10 @@
//!
//! Two motion sources drive an entity's [`Transform`] between the sparse server packets (decisions 0052
//! + 0053):
//! - **[`Spline`]** — a server-authored path (`SMSG_MONSTER_MOVE`), sampled at constant speed by
//! [`spline::sample_splines`]. Creatures ride these.
//! - **[`Spline`]** — a server-authored path, sampled at constant speed by
//! [`spline::sample_splines`]. Creatures ride these. Two sources, one component: a fresh
//! `SMSG_MONSTER_MOVE` ([`spline::monster_move_spline`]), and the walk a unit was **already** on when
//! it streamed into view, joined mid-path from its create block ([`spline::create_spline`], 0708).
//! - **[`RemoteMotion`]** — another player's flag-driven locomotion, integrated by
//! [`remote::extrapolate_remote_units`]: the client's own dead-reckoning in miniature (extrapolate
//! from the last reported state, snap to the truth on the next packet). A jump is a **ballistic
@@ -34,7 +36,8 @@ pub(crate) use remote::RemoteMotion;
pub(in crate::net) use remote::{apply_move, arrival_snap, trace_relay, RelayOutcome};
pub(super) use remote::{drain_pending_moves, extrapolate_remote_units};
pub(super) use spline::{
ground_clamp_creatures, mark_swimming_creatures, monster_move_spline, sample_splines,
create_spline, ground_clamp_creatures, mark_swimming_creatures, monster_move_spline,
sample_splines, trace_create_spline, trace_move_snap,
};
pub(crate) use spline::{CreatureSwimming, Spline};
+165 -1
View File
@@ -2,11 +2,12 @@
//! goes with it ([`ground_clamp_creatures`]) — the creature half of [`super`]'s motion model
//! (decisions 0052/0059/0097).
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use avian3d::prelude::SpatialQuery;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_protocol::EntityKind;
use benilla_protocol::{CreateSpline, EntityKind};
use bevy::prelude::*;
use crate::collision::player_query_filter;
@@ -219,6 +220,127 @@ pub(in crate::net) fn monster_move_spline(
})
}
/// Build the [`Spline`] a unit is **already riding** at the moment it streams into view — its create
/// block's `MOVEFLAG_SPLINE_ENABLED` tail (decision 0708). Same path, same sampler as
/// [`monster_move_spline`]; the one difference is *where the ride starts*: the server tells us how much
/// of the path it has already covered ([`CreateSpline::time_passed_ms`]), so the spline's clock is
/// **back-dated** by that much and [`Spline::sample`] picks the walk up exactly where the server has it,
/// rather than restarting it from the top.
///
/// Returns `None` — "this unit is not walking, leave it at its create pose" — for a degenerate path, a
/// zero duration, or a ride the server has already finished (`time_passed ≥ duration`: the create pose
/// *is* the endpoint). `WOW_CREATE_SPLINE=off` also returns `None` for everything, restoring the
/// pre-0708 behaviour for an A/B.
pub(in crate::net) fn create_spline(spline: CreateSpline) -> Option<Spline> {
if !create_spline_enabled()
|| spline.duration_ms == 0
|| spline.path.len() < 2
|| spline.time_passed_ms >= spline.duration_ms
{
return None;
}
let passed = Duration::from_millis(u64::from(spline.time_passed_ms));
Some(Spline {
points: spline.path,
// `checked_sub` because `Instant` has no epoch to spare: a client started seconds after boot
// can be handed a spline older than its own monotonic clock, and plain `-` panics there.
start: Instant::now()
.checked_sub(passed)
.unwrap_or_else(Instant::now),
duration: Duration::from_millis(u64::from(spline.duration_ms)),
id: spline.id,
grounded: !spline.flying,
})
}
/// One `csp` line per create block that carried a live spline — the **supply** half of the
/// spawn-freeze instrument (decision 0708), written to the shared `WOW_MOVE_TRACE` sink so it
/// interleaves with everything else on one clock. Logged from the wire, *before* the ride is
/// interpreted, so the `WOW_CREATE_SPLINE=off` leg records the same lines as the fixed one and the two
/// are directly diffable.
///
/// `left` is the number that matters: the yards of path still ahead of the server at create time — i.e.
/// exactly how far this unit will drift from us while we hold it still, and therefore how big a jump
/// its next `SMSG_MONSTER_MOVE` has to make up. Read it against the `mmv` lines' realized snaps.
pub(in crate::net) fn trace_create_spline(guid: u64, spline: Option<&CreateSpline>) {
if !crate::dbg_trace::enabled() {
return;
}
let Some(s) = spline else { return };
let length: f32 = s
.path
.windows(2)
.map(|w| {
let (dx, dy, dz) = (w[1][0] - w[0][0], w[1][1] - w[0][1], w[1][2] - w[0][2]);
(dx * dx + dy * dy + dz * dz).sqrt()
})
.sum();
let ridden = s.time_passed_ms as f32 / s.duration_ms.max(1) as f32;
let left = length * (1.0 - ridden).clamp(0.0, 1.0);
crate::dbg_trace::line(
"csp",
&format!(
"{guid:#x} nodes={} len={length:.2} left={left:.2} t={}/{} ms{}{}{}",
s.path.len(),
s.time_passed_ms,
s.duration_ms,
if s.flying { " flying" } else { "" },
if s.cyclic { " cyclic" } else { "" },
if create_spline_enabled() {
""
} else {
" DROPPED(WOW_CREATE_SPLINE=off)"
},
),
);
}
/// One `mmv` line per `SMSG_MONSTER_MOVE` — the **realized** half of the same instrument: the distance
/// from where we were drawing the unit to where the server says its new path starts, which is the
/// teleport the director sees. `?` when the unit has no transform yet (its spawn command hasn't
/// flushed), which is not a snap at all.
///
/// Split into `xy` and `z` on purpose: only the **horizontal** part is a desync. A grounded creature's
/// Z is deliberately ours, not the server's — [`ground_clamp_creatures`] re-derives it from our terrain
/// (decision 0059), so on a slope a correctly-followed creature still reads a Z difference of a yard or
/// two against the wire. Read `xy`; `z` is the terrain disagreement, and reading the 3-D total instead
/// would bury a clean follow in hill noise.
pub(in crate::net) fn trace_move_snap(
guid: u64,
from: Option<[f32; 3]>,
start: [f32; 3],
stop: bool,
duration_ms: u32,
) {
if !crate::dbg_trace::enabled() {
return;
}
let snap = from.map_or("xy=? z=?".to_string(), |f| {
let (dx, dy, dz) = (start[0] - f[0], start[1] - f[1], start[2] - f[2]);
format!("xy={:.2} z={dz:+.2}", (dx * dx + dy * dy).sqrt())
});
crate::dbg_trace::line(
"mmv",
&format!(
"{guid:#x} {snap} start=[{:.2},{:.2},{:.2}] dur={duration_ms}{}",
start[0],
start[1],
start[2],
if stop { " stop" } else { "" },
),
);
}
/// The A/B switch behind [`create_spline`]: `WOW_CREATE_SPLINE=off` drops every create-block spline,
/// which is exactly what the client did before decision 0708 (creatures frozen at first sight until
/// their next `SMSG_MONSTER_MOVE` snapped them forward).
fn create_spline_enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| {
!std::env::var("WOW_CREATE_SPLINE").is_ok_and(|v| matches!(v.as_str(), "off" | "0"))
})
}
/// Interpolate every path-walking entity along its [`Spline`] into its [`Transform`] each frame — so
/// motion stays smooth between the sparse server `MSG_MOVE` packets. Writes translation + rotation
/// only (scale, set by the renderer when it attaches the model, is preserved).
@@ -490,6 +612,48 @@ mod tests {
);
}
/// A create-block spline is joined **where the server already is**, not restarted from the top
/// (decision 0708): a 20-yd path 12 s long, 9 s of it already ridden, samples three quarters
/// along — 15 yd in. Restarting it (the naive read of the same packet) would sample at 0 and put
/// the creature back at the start.
#[test]
fn a_create_spline_joins_the_walk_in_progress() {
let s = create_spline(CreateSpline {
path: vec![[0.0, 0.0, 0.0], [20.0, 0.0, 0.0]],
id: 7,
time_passed_ms: 9_000,
duration_ms: 12_000,
flying: false,
cyclic: false,
})
.expect("a live walk");
let (pos, facing, _) = s.sample(Instant::now());
assert!(
(pos[0] - 15.0).abs() < 0.05 && pos[1].abs() < 1e-3,
"expected three quarters along, got {pos:?}"
);
assert!(facing.unwrap().abs() < 1e-3, "facing down the path");
assert!(s.grounded, "no Flying bit ⇒ terrain-clamped");
}
/// A ride the server has already finished — and a degenerate one — leave the unit at its create
/// pose rather than replaying a walk that is over.
#[test]
fn a_finished_or_degenerate_create_spline_is_no_walk() {
let spline = |time_passed_ms, duration_ms, path: Vec<[f32; 3]>| CreateSpline {
path,
id: 1,
time_passed_ms,
duration_ms,
flying: false,
cyclic: false,
};
let straight = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]];
assert!(create_spline(spline(5_000, 5_000, straight.clone())).is_none());
assert!(create_spline(spline(0, 0, straight)).is_none());
assert!(create_spline(spline(0, 5_000, vec![[1.0, 2.0, 3.0]])).is_none());
}
/// The SNAP past ±π/2 (0516 — `0x7c5573..`: a snap to ±π, not a soft clamp): approaching a
/// hairpin, the look-ahead lands on the return leg, the divergence doubles past π/2, and the
/// bank pins to a full ±π roll — the reference's momentary switchback barrel-roll.
+8
View File
@@ -30,6 +30,7 @@ use bevy::prelude::*;
use crate::lighting::SharedLightBuffer;
mod depthdump;
mod material;
mod model;
mod quads;
@@ -398,6 +399,13 @@ impl ParticleEmitter {
&self.def
}
/// The particle texture handle — read by the phase probe ([`crate::capture`]) to report the
/// GPU-side image state beside the phase membership (a main-world-resident image whose GPU
/// prep failed leaves every draw silently skipped).
pub(crate) fn texture(&self) -> &Handle<Image> {
&self.texture
}
/// The cloud's live world anchor — the census probe's fallback distance subject for an emitter
/// with no [`EmitterFade`] (entity-owned: creatures, GameObjects, spell kits). A faded
/// emitter's distance is measured to its OWNER's sphere instead, because that sphere is what
+197
View File
@@ -0,0 +1,197 @@
//! `$WOW_PARTICLE_DEPTHDUMP` — the CPU half of the particle **depth-contest** measurement (B16).
//!
//! Prints, for every live world-lane quad emitter, the numbers the depth compare is decided by:
//! each particle's quad centre in the world, its rendered half-extent, its view-space z, the NDC
//! depth its quad carries — with the four corners' own NDC depths, so "all corners share the
//! centre's depth" (wow-re `part-flush-emitter-depth.md` §4, the mechanism that lets a flush
//! emitter draw) is *measured* in our pipeline, not assumed — and where the quad lands in
//! **physical pixels**.
//!
//! Pair it with `WOW_DEPTH` at pixels inside the printed rect (same physical-pixel space; MSAA
//! off): that probe answers *what depth the opaque pass left in the buffer*; this one answers
//! *what depth our quad brings to the compare*. Reverse-Z: the fragment survives iff
//! `dquad ≥ d_buffer`. `buffer view-z − quad view-z` at the centre pixel is the emitter's burial
//! as our pipeline actually experiences it — the number to put beside the reference's 0.0058-yd
//! burial / 33–53 % quad survival, without a single screenshot.
//!
//! `WOW_PARTICLE_DEPTHDUMP=<at>[,<frames>]` — dump every frame from `at` seconds elapsed for
//! `<frames>` frames (default 8). One line per particle, capped at 4 per emitter per frame (a
//! flush glow's particles are near-coincident; the cap keeps a 50/s pool readable).
use bevy::camera::Projection;
use bevy::prelude::*;
use super::quads::{draw_gated, particle_center, particle_half, CamBasis, DrawFrame};
use super::Particle;
use benilla_formats::ParticleEmitterDef;
/// Parsed `$WOW_PARTICLE_DEPTHDUMP`: (start seconds, frame count).
static WINDOW: std::sync::LazyLock<Option<(f32, u32)>> = std::sync::LazyLock::new(|| {
let v = std::env::var("WOW_PARTICLE_DEPTHDUMP").ok()?;
let (at, n) = match v.split_once(',') {
Some((a, b)) => (a, b),
None => (v.as_str(), ""),
};
Some((at.trim().parse().ok()?, n.trim().parse().unwrap_or(8)))
});
/// This frame's dump index if the window is open (advances the counter), else `None`. Called once
/// per `simulate_particles` run.
pub(super) fn frame(elapsed: f32, count: &mut u32) -> Option<u32> {
let (at, frames) = (*WINDOW)?;
(elapsed >= at && *count < frames).then(|| {
let f = *count;
*count += 1;
f
})
}
/// Dump one emitter's live pool against the camera it billboards toward.
#[cold]
#[allow(clippy::too_many_arguments)] // the sim loop's full draw context, verbatim
pub(super) fn dump_emitter(
fidx: u32,
def: &ParticleEmitterDef,
particles: &[Particle],
dframe: &DrawFrame,
placement: &Transform,
basis: &CamBasis,
cam_tf: &GlobalTransform,
camera: &Camera,
projection: &Projection,
// The sim's own draw gate: a non-resident texture means `expand_quads` was SKIPPED and none
// of these quads rasterized — numbers that look drawable but weren't. Stated on the line so
// the reader can't pair a withheld pool with a framebuffer.
texture_resident: bool,
// The emitter entity's own visibility, and the mesh asset as the LAST expansion left it (the
// dump runs just before this frame's expansion) — what the draw actually has in hand.
vis: Visibility,
mesh_positions: Option<&Mesh>,
) {
let Some(vp) = camera.physical_viewport_size() else {
return;
};
// The mesh's OWN first quad, exactly as last expansion wrote it (anchor-relative) — read back
// from the asset rather than recomputed, so vertex-data corruption is visible as itself.
if let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) =
mesh_positions.and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION))
{
// Per-quad diagonal |v2−v0| over the WHOLE buffer — the young (big) quads live at the
// tail, and only reading quad 0 (the oldest, smallest) once mis-called this mesh "sane".
let diag = |q: &[[f32; 3]]| (Vec3::from(q[2]) - Vec3::from(q[0])).length();
let quads: Vec<f32> = pos.chunks_exact(4).map(diag).collect();
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
for &d in &quads {
dmin = dmin.min(d);
dmax = dmax.max(d);
}
info!(
"PARTICLE_DEPTHDUMP f={fidx} mesh quads={} diag_min={dmin:.4} diag_max={dmax:.4} \
first=({:.4},{:.4},{:.4}) last=({:.4},{:.4},{:.4})",
quads.len(),
pos[0][0],
pos[0][1],
pos[0][2],
pos[pos.len() - 1][0],
pos[pos.len() - 1][1],
pos[pos.len() - 1][2],
);
}
let view_from_world = cam_tf.to_matrix().inverse();
let clip_from_view = projection.get_clip_from_view();
// (ndc, view-z) of a world point; `None` behind the near plane. The same matrices the frame
// renders with, so `ndc.z` IS the depth the rasterizer interpolates for this vertex.
let project = |world: Vec3| -> Option<(Vec3, f32)> {
let v = view_from_world * world.extend(1.0);
let clip = clip_from_view * v;
(clip.w > 0.0).then(|| (clip.truncate() / clip.w, -v.z))
};
let px = |ndc: Vec3| {
Vec2::new(
(ndc.x + 1.0) * 0.5 * vp.x as f32,
(1.0 - ndc.y) * 0.5 * vp.y as f32,
)
};
info!(
"PARTICLE_DEPTHDUMP f={fidx} emitter bone={} pos=({:.3},{:.3},{:.3}) blend={:?} pool={} \
tex_resident={texture_resident} mesh_verts={} vis={vis:?}",
def.bone,
def.position[0],
def.position[1],
def.position[2],
def.blend,
particles.len(),
mesh_positions.map_or(0, Mesh::count_vertices),
);
// Sample the pool EVENLY by index (retain order = age order), not the first four: the pool's
// head is its oldest, near-contemporary particles, and a cap there hides both the young end
// (the big, bright quads) and the pool's spatial spread across its age span.
let stride = (particles.len() / 4).max(1);
for (i, p) in particles
.iter()
.step_by(stride)
.filter(|p| !draw_gated(def, p))
.take(6)
.enumerate()
{
let center = particle_center(dframe, placement, p);
let half = particle_half(def, placement, p);
// The additive contribution's other factor: the over-life colour this particle's quad
// carries (raw authored values, as `expand_quads` pushes them).
let rgba = def
.over_life
.sample((p.age / def.lifespan).clamp(0.0, 1.0))
.color;
let Some((ndc, viewz)) = project(center) else {
info!("PARTICLE_DEPTHDUMP f={fidx} p{i} behind the camera");
continue;
};
let c_px = px(ndc);
// The quad exactly as `expand_quads` builds it (spin ignored — a spun square covers the
// same disc). Each corner's own NDC depth goes into `corners=[min,max]`: if that span is
// not ≈ dquad on both ends, our billboard is NOT the constant-depth plane the reference's
// mechanism depends on — that would itself be the finding.
let (r, u) = (basis.right * half, basis.up * half);
let (mut lo, mut hi) = (Vec2::splat(f32::MAX), Vec2::splat(f32::MIN));
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
for corner in [
center - r - u,
center + r - u,
center + r + u,
center - r + u,
] {
let Some((n, _)) = project(corner) else {
continue;
};
let p2 = px(n);
lo = lo.min(p2);
hi = hi.max(p2);
dmin = dmin.min(n.z);
dmax = dmax.max(n.z);
}
info!(
"PARTICLE_DEPTHDUMP f={fidx} p{i} age={:.3} center=({:.4},{:.4},{:.4}) half={:.4} \
viewz={:.4} dquad={:.9} corners=[{:.9},{:.9}] px=({:.1},{:.1}) \
rect=({:.0},{:.0})..({:.0},{:.0}) rgba=[{:.2},{:.2},{:.2},{:.2}]",
p.age,
center.x,
center.y,
center.z,
half,
viewz,
ndc.z,
dmin,
dmax,
c_px.x,
c_px.y,
lo.x,
lo.y,
hi.x,
hi.y,
rgba[0],
rgba[1],
rgba[2],
rgba[3],
);
}
}
+8
View File
@@ -77,6 +77,14 @@ impl MaterialExtension for WowParticleExt {
ds.depth_compare = bevy::render::render_resource::CompareFunction::Always;
}
}
// `$WOW_PARTICLE_FLAT=1` — the fragment-input A/B (B16): the shader emits solid magenta,
// ignoring texture, vertex colour and fog. Splits "the quad rasterizes but its inputs
// multiply to zero" from "the fragments never execute".
if std::env::var_os("WOW_PARTICLE_FLAT").is_some() {
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_PARTICLE_FLAT".into());
}
}
if key.bind_group_data.mod2x {
use bevy::render::render_resource::{
BlendComponent, BlendFactor, BlendOperation, BlendState,
+34 -5
View File
@@ -65,6 +65,39 @@ pub(super) struct DrawFrame {
pub attach_rot: Quat,
}
/// One particle's world-space quad centre — THE point [`expand_quads`] rasterizes around, factored
/// out so the `WOW_PARTICLE_DEPTHDUMP` instrument reports the same value the GPU gets, not a
/// re-derivation that can drift.
pub(super) fn particle_center(frame: &DrawFrame, placement: &Transform, p: &Particle) -> Vec3 {
if frame.anchored {
frame.anchor + frame.attach_rot * p.pos
} else {
placement.transform_point(wow_to_bevy([p.pos.x, p.pos.y, p.pos.z]))
}
}
/// The twinklePercent draw-gate — mirrors [`expand_quads`]'s `0x7b2adc` skip, exported so the
/// depth dump reports only particles that actually emit a quad. **If that condition changes,
/// change this.**
pub(super) fn draw_gated(def: &ParticleEmitterDef, p: &Particle) -> bool {
let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase);
def.twinkle_percent < 1.0 && noise > def.twinkle_percent
}
/// One particle's rendered half-extent — mirrors the `half` term inside [`expand_quads`] (over-life
/// size × gated twinkle × instance scale), which computes it inline because it needs the noise and
/// over-life samples for other attributes too. **If that expression changes, change this.**
pub(super) fn particle_half(def: &ParticleEmitterDef, placement: &Transform, p: &Particle) -> f32 {
let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase);
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
let scale = if def.scale_size_by_instance() {
placement.scale.x.max(1e-4)
} else {
1.0
};
def.over_life.sample(u_age).size * def.twinkle(noise) * scale
}
/// Expand one pool into its billboard mesh (rewritten in place). Vertices are ANCHOR-relative —
/// the caller carries the anchor on the mesh entity's transform (the transparent-pass sort key).
pub(super) fn expand_quads(
@@ -148,11 +181,7 @@ pub(super) fn expand_quads(
// rotation on an attached model (`DrawMx = A·Translate·V`, the heading-since-birth
// fan); model mode folds the whole live placement transform (the reference's rt-0x100
// render fold, `0x7b3d20`).
let center = if anchored {
anchor + frame.attach_rot * p.pos
} else {
placement.transform_point(wow_to_bevy([p.pos.x, p.pos.y, p.pos.z]))
};
let center = particle_center(frame, placement, p);
// `size` is the half-extent: the reference quad corners are ±1.0 (verified in wow-5875-re,
// `quad_expand`), so a vertex sits at `center ± size` and the world quad edge spans 2·size.
// Rendered half-size (wow-re B2, byte-verified `0x7b2a50`): the over-life ramp is the
+28 -2
View File
@@ -2,6 +2,7 @@ use avian3d::prelude::SpatialQuery;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use bevy::camera::primitives::{Frustum, Sphere as CullSphere};
use bevy::camera::visibility::RenderLayers;
use bevy::camera::Projection;
use bevy::prelude::*;
use crate::player::WorldCamera;
@@ -168,7 +169,7 @@ pub(super) fn simulate_particles(
// The far-clip wall — the emitter draw-set gate's depth bound (see the gate below).
view: Res<crate::view::ViewDistance>,
mut commands: Commands,
cam: Query<(&GlobalTransform, &Frustum), With<WorldCamera>>,
cam: Query<(&GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
// both `&mut GlobalTransform` queries below.
transforms: Query<&GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
@@ -218,8 +219,10 @@ pub(super) fn simulate_particles(
// `AnimationPlayer` names the playing sequence + clip time, same resolution as the
// material-alpha sampler (`doodad_anim::playing_seq`).
hosts: Query<(&AnimationPlayer, &benilla_assets::ModelAnimations)>,
// `$WOW_PARTICLE_DEPTHDUMP`'s frame counter (see [`super::depthdump`]); inert without the env.
mut dump_count: Local<u32>,
) {
let Ok((cam_tf, frustum)) = cam.single() else {
let Ok((cam_tf, frustum, camera, projection)) = cam.single() else {
return;
};
// Clamp dt so a load hitch doesn't fling every particle out of existence in one step.
@@ -237,6 +240,8 @@ pub(super) fn simulate_particles(
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
// doodad's own cull along, so an emitter and its owner cross the wall together.
let cam_fwd = Vec3::from(cam_tf.forward());
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count);
for (entity, mut emitter, mut entity_tf, mut entity_global, fade, mut vis, layers) in
&mut emitters
@@ -750,6 +755,27 @@ pub(super) fn simulate_particles(
up: e_up,
face_normal: e_normal,
};
// `$WOW_PARTICLE_DEPTHDUMP` (B16): the depth numbers this pool brings to the compare.
// Booth emitters are skipped — their pixels belong to a booth camera's target, not the
// world depth buffer `WOW_DEPTH` reads.
if let Some(fidx) = dump_frame {
if !is_booth && def.geometry_model.is_none() && !particles.is_empty() {
super::depthdump::dump_emitter(
fidx,
def,
particles,
&frame,
placement,
&cam,
cam_tf,
camera,
projection,
images.contains(&*texture),
*vis,
meshes.get(&*mesh),
);
}
}
if let Some(m) = meshes.get_mut(&*mesh) {
// A GEOMETRY (model-particle) emitter never draws quads — the reference's render
// dispatch skips the whole quad path when the model mode is on; its instances
+38
View File
@@ -89,6 +89,44 @@ pub(super) fn swim(feet_y: f32, surface_y: f32, swimming: bool, h: f32) {
);
}
/// How the post-teleport settle gate **ended** — the whole diagnosis of a fall-through report.
///
/// `found` means a floor was under the feet and the hold released onto it; `!found` is the
/// [`super::SETTLE_TIMEOUT`] backstop firing, which switches gravity on having never seen ground.
/// The distinction is invisible from inside the game (both look like "the loading screen went
/// away") and it is the difference between a world that streamed in time and one that did not, so
/// the timeout end is also a `warn!` on the ordinary log — a reporter's paste can then name it
/// without owning a trace.
pub(super) fn settle(found: bool, waited: f32, pos: bevy::prelude::Vec3) {
if !found {
bevy::log::warn!(
"settle: TIMED OUT after {waited:.2}s with no ground under the feet at \
({:.1},{:.1},{:.1}) — releasing into free fall. If a building stands here, its \
collider had not finished streaming and the body is about to fall through it.",
pos.x,
pos.y,
pos.z,
);
}
if !dbg_trace::enabled() {
return;
}
dbg_trace::line(
"sett",
&format!(
"{} after {waited:6.2}s at ({:8.2},{:7.2},{:8.2})",
if found {
"ground found"
} else {
"TIMED OUT "
},
pos.x,
pos.y,
pos.z,
),
);
}
static PREV_GROUNDED: AtomicBool = AtomicBool::new(true);
pub(super) fn frame(f: Frame) {
+27 -7
View File
@@ -33,8 +33,9 @@ use crate::collision::player_query_filter;
use super::{
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE,
JUMP_SPEED, LAND_PROBE, SETTLE_REACH, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK,
STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
JUMP_SPEED, LAND_PROBE, SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STEP_SLOPE_RATIO,
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
WEDGE_STILL_FRAMES,
};
/// What the step decided — read by the move-flags / wire logic that follows it in `control`.
@@ -106,11 +107,30 @@ pub(super) fn step(
// small so it's the *close* floor we stand on, not distant terrain seen through an unloaded
// building) or once we time out. Until then `held` keeps gravity OFF and freezes us in place,
// so we don't fall through the not-yet-loaded city/building (the loading screen stays up too).
if player.settling
&& (probe_down(center, SETTLE_REACH).is_some_and(|h| h.normal1.y >= GROUND_COS)
|| time.elapsed_secs() >= player.settle_deadline)
{
player.settling = false;
//
// Which of the two ends it is the whole diagnosis of a fall-through report, so say it out loud.
// The timeout branch is a **fail-open**: it switches gravity on without ever having found a
// floor, so a reporter's log has to be able to name it — a `warn!` here is the difference
// between a bug we can confirm and one we can only fail to reproduce.
//
// Neither end may be judged while the world under us is still the one we just left
// ([`Player::world_stale`]) — that probe would be asking the wrong map, and answering "found"
// is the instance-entry fall-through. The deadline is held off with it, so the timeout budget
// measures time spent waiting for the *destination's* floor rather than being spent before the
// destination exists; a world that never arrives keeps the hold (and the loading screen) rather
// than dropping the body into the void.
if player.settling {
if player.world_stale {
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
} else {
let found = probe_down(center, SETTLE_REACH).is_some_and(|h| h.normal1.y >= GROUND_COS);
let timed_out = time.elapsed_secs() >= player.settle_deadline;
if found || timed_out {
player.settling = false;
let waited = SETTLE_TIMEOUT - (player.settle_deadline - time.elapsed_secs());
move_trace::settle(found, waited, player.pos);
}
}
}
let held = player.settling;
let on_floor = !held && on_walkable && player.vel_y <= 0.0;
+18
View File
@@ -228,6 +228,24 @@ pub(crate) struct Player {
pub(crate) settling: bool,
/// `Time::elapsed_secs` deadline to give up settling and release (see [`Player::settling`]).
pub(super) settle_deadline: f32,
/// **The colliders under our feet may still belong to the map we just left.** Set at every
/// snap, cleared by the terrain streamer once the destination's own world is resident.
///
/// The snap and the settle probe run in the SAME system (`control`: `wire_in` then the mover),
/// one whole `WorldStage` *before* the streamer swaps maps — so on the arrival frame the physics
/// world is still entirely the old map's, and its despawn is a deferred command on top of that.
/// A settle probe that fires into that window is asking the wrong world whether there is a floor.
///
/// It normally answers "no" and costs nothing: a `.go` across the continent lands nowhere near
/// the old ground. But an **instance entrance sits within a yard of its own outdoor portal** —
/// Zul'Gurub's pad is z 92.53 against the Stranglethorn ground you were standing on at 92.30 —
/// so the probe hit the world we were leaving, declared the floor found, released the hold on
/// frame 0, and dropped the body 15 yd through the WMO onto the terrain below (the ZG and
/// Stratholme entry fall-through). Only walking a portal could show it; `.go` between the two
/// maps never reproduces, because only the portal puts the two floors on top of each other.
/// Cleared by `terrain_stream` (the only system that knows which map the resident colliders
/// belong to); read by the mover's settle gate.
pub(crate) world_stale: bool,
/// A same-map teleport landed: the server relocated the mover, so any in-progress self
/// server-ride (charge/taxi) is **void** — vmangos teleports at ITS flight end (its own spline
/// finishes ~latency before ours) and its spline-done handler ignores acks while the teleport
+7
View File
@@ -92,6 +92,9 @@ pub(super) fn apply_server_moves(
cam.yaw = w.orientation;
player.settling = true; // hold (gravity off) until the new map's ground streams in
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
// …and the physics world under the new position is still the OLD map's until the
// streamer swaps it, one stage from now. See [`Player::world_stale`].
player.world_stale = true;
}
player.move_flags = 0;
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
@@ -128,6 +131,9 @@ pub(super) fn apply_server_moves(
player.airborne_since = None; // a snap ends any in-progress jump arc (no phantom FALL_LAND)
player.settling = true; // hold (gravity off) until the destination's ground/buildings load
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
// A far same-map teleport relocates us over ground that has not streamed either — the
// tiles we were standing on are still the resident ones. See [`Player::world_stale`].
player.world_stale = true;
// The relocation voids any in-progress self server-ride (the taxi flight-end teleport
// beats our own spline end by ~latency): `drive_self_ride` takes this flag next frame
// and drops the ride instead of mirroring the stale flight pose back over this snap
@@ -206,6 +212,7 @@ pub(super) fn apply_server_moves(
player.active = true;
player.settling = true; // settle onto the initial ground once it loads (don't fall through)
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
player.world_stale = true; // nothing is streamed yet at all — see [`Player::world_stale`]
cam.yaw = 0.0;
cam.pitch = -0.45;
player.face_yaw = 0.0;
+10 -1
View File
@@ -291,7 +291,7 @@ fn stream_terrain(
liquid_assets: Option<Res<LiquidAssets>>,
clutter: Option<Res<GroundClutter>>,
clutter_cfg: Option<Res<ClutterConfig>>,
player: Res<Player>,
mut player: ResMut<Player>,
camera: Query<&Transform, With<WorldCamera>>,
shared_light: Option<Res<SharedLightBuffer>>,
cfg: Option<Res<RenderConfig>>,
@@ -559,6 +559,15 @@ fn stream_terrain(
p.ready += usize::from(up);
p.focus_resident &= up;
}
// The streamer is the only authority on *which map* the colliders under the player belong
// to, so it is what lifts the mover's post-snap hold ([`Player::world_stale`]). Residency
// here means this map's own tile (or, on a WMO-only map, its one building) is spawned —
// which is only reachable after the swap above drained every tile of the map we left. Until
// then a settle probe would be answered by the old world, and at an instance entrance the
// old floor sits within a yard of the new one.
if p.focus_resident && p.total > 0 {
player.world_stale = false;
}
}
}