From fd54ea919721589d3133ae643ef6d3f8ac72f3cc Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 27 Jul 2026 19:38:01 +0700 Subject: [PATCH] walkers keep walking, and dungeon entrances stop dropping you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../examples/area_trigger_at.rs | 76 ++++++ .../examples/wmo_collision_cost.rs | 137 +++++++++++ crates/benilla-protocol/src/events.rs | 8 +- crates/benilla-protocol/src/events/decode.rs | 1 + crates/benilla-protocol/src/lib.rs | 6 +- crates/benilla-protocol/src/messages/mod.rs | 4 +- .../src/messages/monster_move.rs | 37 ++- .../src/messages/update_object/mod.rs | 2 +- .../src/messages/update_object/movement.rs | 79 ++++++- .../benilla-protocol/tests/update_object.rs | 134 +++++++++++ .../benilla/assets/shaders/wow_particle.wgsl | 7 + crates/benilla/src/bgwin.rs | 219 ++++++++++++++---- crates/benilla/src/capture/depth_probe.rs | 26 ++- crates/benilla/src/capture/phase_probe.rs | 115 +++++++-- crates/benilla/src/main.rs | 18 +- crates/benilla/src/net/apply.rs | 2 + crates/benilla/src/net/apply/objects.rs | 44 +++- crates/benilla/src/net/motion.rs | 9 +- crates/benilla/src/net/motion/spline.rs | 166 ++++++++++++- crates/benilla/src/particles.rs | 8 + crates/benilla/src/particles/depthdump.rs | 197 ++++++++++++++++ crates/benilla/src/particles/material.rs | 8 + crates/benilla/src/particles/quads.rs | 39 +++- crates/benilla/src/particles/sim.rs | 30 ++- crates/benilla/src/player/move_trace.rs | 38 +++ crates/benilla/src/player/mover.rs | 34 ++- crates/benilla/src/player/state.rs | 18 ++ crates/benilla/src/player/wire_in.rs | 7 + crates/benilla/src/terrain_stream.rs | 11 +- 29 files changed, 1371 insertions(+), 109 deletions(-) create mode 100644 crates/benilla-formats/examples/area_trigger_at.rs create mode 100644 crates/benilla-formats/examples/wmo_collision_cost.rs create mode 100644 crates/benilla/src/particles/depthdump.rs diff --git a/crates/benilla-formats/examples/area_trigger_at.rs b/crates/benilla-formats/examples/area_trigger_at.rs new file mode 100644 index 00000000..21dea93b --- /dev/null +++ b/crates/benilla-formats/examples/area_trigger_at.rs @@ -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…]` +//! +//! 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 = std::env::args().skip(1).collect(); + if args.is_empty() { + anyhow::bail!("usage: area_trigger_at ..."); + } + 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(()) +} diff --git a/crates/benilla-formats/examples/wmo_collision_cost.rs b/crates/benilla-formats/examples/wmo_collision_cost.rs new file mode 100644 index 00000000..1f6c3767 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_collision_cost.rs @@ -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 -- ...` +//! 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 = std::env::args().skip(1).collect(); + if pats.is_empty() { + anyhow::bail!("usage: wmo_collision_cost ..."); + } + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let names: Vec = 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(()) +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index f2cd7a4b..9a27c9af 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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, + /// 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, /// 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). diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 8e98de5e..4555e0ef 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -785,6 +785,7 @@ fn decode_objects(objects: Vec) -> Vec { speeds, transport_progress: movement.transport_progress, transport: movement.transport, + spline: movement.spline, fields: mask, }); } diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index 3121a802..53f25569 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -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}; diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 1ed5d741..ca73e5e4 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, }; diff --git a/crates/benilla-protocol/src/messages/monster_move.rs b/crates/benilla-protocol/src/messages/monster_move.rs index a34ffc24..af429b92 100644 --- a/crates/benilla-protocol/src/messages/monster_move.rs +++ b/crates/benilla-protocol/src/messages/monster_move.rs @@ -102,6 +102,13 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result { /// 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 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, 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 diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index e32e6eb9..cc8537e1 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -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 { diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs index de004916..9e43a428 100644 --- a/crates/benilla-protocol/src/messages/update_object/movement.rs +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -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, + /// 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, } 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, }) } } diff --git a/crates/benilla-protocol/tests/update_object.rs b/crates/benilla-protocol/tests/update_object.rs index fa928303..48bee676 100644 --- a/crates/benilla-protocol/tests/update_object.rs +++ b/crates/benilla-protocol/tests/update_object.rs @@ -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__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 diff --git a/crates/benilla/assets/shaders/wow_particle.wgsl b/crates/benilla/assets/shaders/wow_particle.wgsl index ce521592..bd6fc8f2 100644 --- a/crates/benilla/assets/shaders/wow_particle.wgsl +++ b/crates/benilla/assets/shaders/wow_particle.wgsl @@ -58,6 +58,12 @@ const RAIN_FOG_GREY: vec3 = vec3(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(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 } diff --git a/crates/benilla/src/bgwin.rs b/crates/benilla/src/bgwin.rs index fa051341..25f4993f 100644 --- a/crates/benilla/src/bgwin.rs +++ b/crates/benilla/src/bgwin.rs @@ -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) { - 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>, + mut windows: Query<&mut Window, With>, + mut span: Local>, + mut phase: Local, + ) { + 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); } } } diff --git a/crates/benilla/src/capture/depth_probe.rs b/crates/benilla/src/capture/depth_probe.rs index 6d058955..a0e37a78 100644 --- a/crates/benilla/src/capture/depth_probe.rs +++ b/crates/benilla/src/capture/depth_probe.rs @@ -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, + ) + }, ); } } diff --git a/crates/benilla/src/capture/phase_probe.rs b/crates/benilla/src/capture/phase_probe.rs index 59a352ad..ab526f55 100644 --- a/crates/benilla/src/capture/phase_probe.rs +++ b/crates/benilla/src/capture/phase_probe.rs @@ -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::().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::().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::::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, /// 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, AssetId)>); + /// 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 = 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, + time: Res