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